mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: donut allocation chart + History/Allocation tabs on Crypto tab
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
148
frontend/src/components/crypto/CryptoAllocationBody.tsx
Normal file
148
frontend/src/components/crypto/CryptoAllocationBody.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||||
|
<p className="font-medium text-text-primary">{slice.key === "other" ? otherLabel : slice.name}</p>
|
||||||
|
<p className="text-text-muted">
|
||||||
|
{maskAmount(formatCurrency(slice.amount), hidden)} · {slice.percent.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 <p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>;
|
||||||
|
}
|
||||||
|
const slices = buildSlices(holdings);
|
||||||
|
if (slices.length === 0) {
|
||||||
|
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
|
||||||
|
const donutData = slices.map((slice) => ({ ...slice, percent: (slice.amount / total) * 100 }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 sm:flex-row sm:items-center sm:gap-6">
|
||||||
|
<div className="h-48 w-48 shrink-0 sm:h-56 sm:w-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={donutData}
|
||||||
|
dataKey="amount"
|
||||||
|
nameKey="name"
|
||||||
|
innerRadius="68%"
|
||||||
|
outerRadius="100%"
|
||||||
|
paddingAngle={donutData.length > 1 ? 2 : 0}
|
||||||
|
stroke="var(--surface-1)"
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
>
|
||||||
|
{donutData.map((slice) => (
|
||||||
|
<Cell key={slice.key} fill={slice.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<DonutTooltip hidden={hidden} otherLabel={t("crypto.allocation.other")} />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="w-full min-w-0 flex-1 divide-y divide-gridline">
|
||||||
|
{donutData.map((slice) => (
|
||||||
|
<li key={slice.key} className="flex items-center gap-3 py-2 first:pt-0">
|
||||||
|
{slice.thumbUrl ? (
|
||||||
|
<img src={slice.thumbUrl} alt="" className="h-6 w-6 shrink-0 rounded-full" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: `${slice.color}26` }}
|
||||||
|
>
|
||||||
|
<Bitcoin size={12} style={{ color: slice.color }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
||||||
|
{slice.key === "other" ? t("crypto.allocation.other") : slice.name}
|
||||||
|
{slice.symbol && <span className="text-text-muted"> · {slice.symbol}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-right text-sm font-medium tabular-nums text-text-primary">
|
||||||
|
{slice.percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
<span className="w-20 shrink-0 text-right text-xs tabular-nums text-text-muted">
|
||||||
|
{maskAmount(formatCurrency(slice.amount), hidden)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("crypto.allocation.title")}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>
|
|
||||||
) : slices.length === 0 ? (
|
|
||||||
<p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-surface-2">
|
|
||||||
{slices.map((slice) => (
|
|
||||||
<div
|
|
||||||
key={slice.key}
|
|
||||||
style={{ width: `${(slice.amount / total) * 100}%`, backgroundColor: slice.color }}
|
|
||||||
className="h-full first:rounded-l-full last:rounded-r-full"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul className="mt-4 divide-y divide-gridline border-t border-gridline">
|
|
||||||
{slices.map((slice) => {
|
|
||||||
const percent = (slice.amount / total) * 100;
|
|
||||||
return (
|
|
||||||
<li key={slice.key} className="flex items-center gap-3 py-2.5 first:pt-3">
|
|
||||||
{slice.thumbUrl ? (
|
|
||||||
<img src={slice.thumbUrl} alt="" className="h-7 w-7 shrink-0 rounded-full" />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
|
|
||||||
style={{ backgroundColor: `${slice.color}26` }}
|
|
||||||
>
|
|
||||||
<Bitcoin size={14} style={{ color: slice.color }} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
|
|
||||||
{slice.key === "other" ? t("crypto.allocation.other") : slice.name}
|
|
||||||
{slice.symbol && <span className="text-text-muted"> · {slice.symbol}</span>}
|
|
||||||
</span>
|
|
||||||
<span className="hidden w-24 shrink-0 items-center gap-2 sm:flex">
|
|
||||||
<span className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
|
||||||
<span className="block h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: slice.color }} />
|
|
||||||
</span>
|
|
||||||
<span className="w-9 shrink-0 text-right text-xs text-text-muted tabular-nums">{percent.toFixed(0)}%</span>
|
|
||||||
</span>
|
|
||||||
<span className="w-14 shrink-0 text-right text-xs text-text-muted tabular-nums sm:hidden">
|
|
||||||
{percent.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
|
|
||||||
{maskAmount(formatCurrency(slice.amount), hidden)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
|
||||||
<p className="text-text-muted">{formatAxisDate(point.date)}</p>
|
|
||||||
<p className="font-medium text-text-primary">{maskAmount(formatCurrency(point.value), hidden)}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 (
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-start">
|
|
||||||
<div>
|
|
||||||
<CardTitle>{t("nav.crypto")}</CardTitle>
|
|
||||||
<p className="mt-1.5 flex items-center gap-2 text-2xl font-semibold tabular-nums text-text-primary sm:text-[28px]">
|
|
||||||
{isLoading ? "…" : maskAmount(formatCurrency(history?.current ?? 0), hidden)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={hidden ? t("crypto.chart.show") : t("crypto.chart.hide")}
|
|
||||||
onClick={onToggleHidden}
|
|
||||||
className="text-text-muted hover:text-text-primary"
|
|
||||||
>
|
|
||||||
{hidden ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
{history && (
|
|
||||||
<p className="mt-1 flex items-center gap-1 text-sm font-medium" style={{ color: trendColor }}>
|
|
||||||
{isPositive ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
|
|
||||||
{maskAmount(formatSignedCurrency(history.change_amount), hidden)}
|
|
||||||
{history.change_percent !== null && ` (${isPositive ? "+" : ""}${history.change_percent.toFixed(1)}%)`}
|
|
||||||
<span className="font-normal text-text-muted">{t("netWorth.periodSuffix")}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<CryptoRangeSelector value={range} onChange={onRangeChange} />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="h-56 w-full sm:h-64">
|
|
||||||
{isLoading || chartData.length === 0 ? (
|
|
||||||
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
|
||||||
{isLoading ? t("common.loading") : t("netWorth.noChartData")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="cryptoHistoryFill" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor={trendColor} stopOpacity={0.18} />
|
|
||||||
<stop offset="100%" stopColor={trendColor} stopOpacity={0} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<YAxis hide domain={["auto", "auto"]} />
|
|
||||||
<Tooltip content={<ChartTooltip hidden={hidden} />} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke={trendColor}
|
|
||||||
strokeWidth={2}
|
|
||||||
fill="url(#cryptoHistoryFill)"
|
|
||||||
isAnimationActive={false}
|
|
||||||
activeDot={{ r: 4, strokeWidth: 0 }}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{chartData.length > 1 && (
|
|
||||||
<div className="mt-2 flex justify-between text-xs text-text-muted">
|
|
||||||
<span>{formatAxisDate(chartData[0].date)}</span>
|
|
||||||
<span>{formatAxisDate(chartData[chartData.length - 1].date)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
83
frontend/src/components/crypto/CryptoHistoryChartBody.tsx
Normal file
83
frontend/src/components/crypto/CryptoHistoryChartBody.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="rounded-lg border border-border bg-surface-1 px-3 py-2 text-sm shadow-md">
|
||||||
|
<p className="text-text-muted">{formatAxisDate(point.date)}</p>
|
||||||
|
<p className="font-medium text-text-primary">{maskAmount(formatCurrency(point.value), hidden)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<>
|
||||||
|
<div className="h-56 w-full sm:h-64">
|
||||||
|
{isLoading || chartData.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-text-muted">
|
||||||
|
{isLoading ? t("common.loading") : t("netWorth.noChartData")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 8, right: 4, bottom: 0, left: 4 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cryptoHistoryFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={trendColor} stopOpacity={0.18} />
|
||||||
|
<stop offset="100%" stopColor={trendColor} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<YAxis hide domain={["auto", "auto"]} />
|
||||||
|
<Tooltip content={<ChartTooltip hidden={hidden} />} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={trendColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="url(#cryptoHistoryFill)"
|
||||||
|
isAnimationActive={false}
|
||||||
|
activeDot={{ r: 4, strokeWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{chartData.length > 1 && (
|
||||||
|
<div className="mt-2 flex justify-between text-xs text-text-muted">
|
||||||
|
<span>{formatAxisDate(chartData[0].date)}</span>
|
||||||
|
<span>{formatAxisDate(chartData[chartData.length - 1].date)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
frontend/src/components/crypto/CryptoOverviewCard.tsx
Normal file
96
frontend/src/components/crypto/CryptoOverviewCard.tsx
Normal file
@@ -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<OverviewTab>("history");
|
||||||
|
|
||||||
|
const isPositive = history ? Number(history.change_amount) >= 0 : true;
|
||||||
|
const trendColor = isPositive ? "var(--success)" : "var(--danger)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-start">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{t("nav.crypto")}</CardTitle>
|
||||||
|
<p className="mt-1.5 flex items-center gap-2 text-2xl font-semibold tabular-nums text-text-primary sm:text-[28px]">
|
||||||
|
{maskAmount(formatCurrency(totalValue), hidden)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={hidden ? t("crypto.chart.show") : t("crypto.chart.hide")}
|
||||||
|
onClick={onToggleHidden}
|
||||||
|
className="text-text-muted hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{hidden ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{history && (
|
||||||
|
<p className="mt-1 flex items-center gap-1 text-sm font-medium" style={{ color: trendColor }}>
|
||||||
|
{isPositive ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
|
||||||
|
{maskAmount(formatSignedCurrency(history.change_amount), hidden)}
|
||||||
|
{history.change_percent !== null && ` (${isPositive ? "+" : ""}${history.change_percent.toFixed(1)}%)`}
|
||||||
|
<span className="font-normal text-text-muted">{t("netWorth.periodSuffix")}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<PillSelector
|
||||||
|
options={[
|
||||||
|
{ value: "history" as const, label: t("crypto.overview.history") },
|
||||||
|
{ value: "allocation" as const, label: t("crypto.overview.allocation") },
|
||||||
|
]}
|
||||||
|
value={tab}
|
||||||
|
onChange={setTab}
|
||||||
|
/>
|
||||||
|
{tab === "history" && <CryptoRangeSelector value={range} onChange={onRangeChange} />}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{tab === "history" ? (
|
||||||
|
<CryptoHistoryChartBody history={history} isLoading={isHistoryLoading} hidden={hidden} />
|
||||||
|
) : (
|
||||||
|
<CryptoAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -150,6 +150,8 @@ const ru = {
|
|||||||
"crypto.chart.show": "Показать сумму",
|
"crypto.chart.show": "Показать сумму",
|
||||||
"crypto.chart.hide": "Скрыть сумму",
|
"crypto.chart.hide": "Скрыть сумму",
|
||||||
"crypto.allocation.title": "Распределение по монетам",
|
"crypto.allocation.title": "Распределение по монетам",
|
||||||
|
"crypto.overview.history": "История",
|
||||||
|
"crypto.overview.allocation": "Аллокация",
|
||||||
"crypto.allocation.other": "Другое",
|
"crypto.allocation.other": "Другое",
|
||||||
"crypto.stats.allTimeProfit": "Прибыль за всё время",
|
"crypto.stats.allTimeProfit": "Прибыль за всё время",
|
||||||
"crypto.stats.costBasis": "Вложено всего",
|
"crypto.stats.costBasis": "Вложено всего",
|
||||||
@@ -609,6 +611,8 @@ const en: Record<keyof typeof ru, string> = {
|
|||||||
"crypto.chart.show": "Show amount",
|
"crypto.chart.show": "Show amount",
|
||||||
"crypto.chart.hide": "Hide amount",
|
"crypto.chart.hide": "Hide amount",
|
||||||
"crypto.allocation.title": "Allocation by coin",
|
"crypto.allocation.title": "Allocation by coin",
|
||||||
|
"crypto.overview.history": "History",
|
||||||
|
"crypto.overview.allocation": "Allocation",
|
||||||
"crypto.allocation.other": "Other",
|
"crypto.allocation.other": "Other",
|
||||||
"crypto.stats.allTimeProfit": "All-time profit",
|
"crypto.stats.allTimeProfit": "All-time profit",
|
||||||
"crypto.stats.costBasis": "Total invested",
|
"crypto.stats.costBasis": "Total invested",
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { Plus, RefreshCw } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { CryptoAddModal } from "@/components/crypto/CryptoAddModal";
|
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 { CryptoHoldingsTable } from "@/components/crypto/CryptoHoldingsTable";
|
||||||
|
import { CryptoOverviewCard } from "@/components/crypto/CryptoOverviewCard";
|
||||||
import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
|
import { CryptoStatsRow } from "@/components/crypto/CryptoStatsRow";
|
||||||
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
|
import { CryptoTransactionHistoryModal } from "@/components/crypto/CryptoTransactionHistoryModal";
|
||||||
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
|
import { CryptoTransactionModal } from "@/components/crypto/CryptoTransactionModal";
|
||||||
@@ -36,6 +35,7 @@ export function CryptoPage() {
|
|||||||
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
const [historyHolding, setHistoryHolding] = useState<CryptoHolding | null>(null);
|
||||||
|
|
||||||
const holdings = data?.holdings ?? [];
|
const holdings = data?.holdings ?? [];
|
||||||
|
const totalValue = holdings.reduce((sum, holding) => sum + (holding.value !== null ? Number(holding.value) : 0), 0);
|
||||||
|
|
||||||
function handleDelete(holding: CryptoHolding) {
|
function handleDelete(holding: CryptoHolding) {
|
||||||
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
|
if (window.confirm(t("crypto.confirmDelete", { name: holding.name }))) {
|
||||||
@@ -45,19 +45,20 @@ export function CryptoPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<CryptoHistoryChart
|
<CryptoOverviewCard
|
||||||
|
totalValue={totalValue}
|
||||||
history={history}
|
history={history}
|
||||||
isLoading={isHistoryLoading}
|
isHistoryLoading={isHistoryLoading}
|
||||||
range={range}
|
range={range}
|
||||||
onRangeChange={setRange}
|
onRangeChange={setRange}
|
||||||
|
holdings={holdings}
|
||||||
|
isHoldingsLoading={isLoading}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
onToggleHidden={() => setHidden((prev) => !prev)}
|
onToggleHidden={() => setHidden((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CryptoStatsRow holdings={holdings} isLoading={isLoading} hidden={hidden} />
|
<CryptoStatsRow holdings={holdings} isLoading={isLoading} hidden={hidden} />
|
||||||
|
|
||||||
<CryptoAllocationCard holdings={holdings} isLoading={isLoading} hidden={hidden} />
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user