diff --git a/frontend/src/components/crypto/CryptoAllocationCard.tsx b/frontend/src/components/crypto/CryptoAllocationCard.tsx
index 74e0d90..2a276f0 100644
--- a/frontend/src/components/crypto/CryptoAllocationCard.tsx
+++ b/frontend/src/components/crypto/CryptoAllocationCard.tsx
@@ -1,12 +1,13 @@
import { Bitcoin } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
-import { formatCurrency } from "@/lib/format";
+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
@@ -49,7 +50,7 @@ function buildSlices(holdings: CryptoHolding[]): Slice[] {
return [...top, { key: "other", name: "Other", symbol: null, thumbUrl: null, amount: otherTotal, color: OTHER_COLOR }];
}
-export function CryptoAllocationCard({ holdings, isLoading }: CryptoAllocationCardProps) {
+export function CryptoAllocationCard({ holdings, isLoading, hidden }: CryptoAllocationCardProps) {
const { t } = useTranslation();
const slices = buildSlices(holdings);
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
@@ -105,7 +106,7 @@ export function CryptoAllocationCard({ holdings, isLoading }: CryptoAllocationCa
{percent.toFixed(0)}%
- {formatCurrency(slice.amount)}
+ {maskAmount(formatCurrency(slice.amount), hidden)}
);
diff --git a/frontend/src/components/crypto/CryptoHistoryChart.tsx b/frontend/src/components/crypto/CryptoHistoryChart.tsx
index 86c498e..abab181 100644
--- a/frontend/src/components/crypto/CryptoHistoryChart.tsx
+++ b/frontend/src/components/crypto/CryptoHistoryChart.tsx
@@ -1,9 +1,8 @@
-import { useState } from "react";
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 } from "@/lib/format";
+import { formatCurrency, formatSignedCurrency, getIntlLocale, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHistoryResponse, CryptoRange } from "@/types";
@@ -12,29 +11,46 @@ interface CryptoHistoryChartProps {
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 }: { active?: boolean; payload?: Array<{ payload: { date: string; value: number } }> }) {
+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)}
-
{formatCurrency(point.value)}
+
{maskAmount(formatCurrency(point.value), hidden)}
);
}
/** The Crypto tab's headline chart — same recipe as NetWorthChart, plus an
- * eye toggle to mask the big number (screen-over-someone's-shoulder
- * privacy, doesn't hide anything else on the page). */
-export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }: CryptoHistoryChartProps) {
+ * 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 [hidden, setHidden] = useState(false);
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) })) ?? [];
@@ -45,20 +61,20 @@ export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }:
{t("nav.crypto")}
- {isLoading ? "…" : hidden ? "••••••" : formatCurrency(history?.current ?? 0)}
+ {isLoading ? "…" : maskAmount(formatCurrency(history?.current ?? 0), hidden)}
- {history && !hidden && (
+ {history && (
{isPositive ? : }
- {formatSignedCurrency(history.change_amount)}
+ {maskAmount(formatSignedCurrency(history.change_amount), hidden)}
{history.change_percent !== null && ` (${isPositive ? "+" : ""}${history.change_percent.toFixed(1)}%)`}
{t("netWorth.periodSuffix")}
@@ -82,7 +98,7 @@ export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }:
-
} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
+
} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
void;
onViewHistory: (holding: CryptoHolding) => void;
onDelete: (holding: CryptoHolding) => void;
@@ -22,7 +23,7 @@ function PercentCell({ value }: { value: string | null }) {
);
}
-export function CryptoHoldingsTable({ items, onTrade, onViewHistory, onDelete }: CryptoHoldingsTableProps) {
+export function CryptoHoldingsTable({ items, hidden, onTrade, onViewHistory, onDelete }: CryptoHoldingsTableProps) {
const { t } = useTranslation();
if (items.length === 0) {
@@ -75,7 +76,9 @@ export function CryptoHoldingsTable({ items, onTrade, onViewHistory, onDelete }:
- {holding.current_price !== null ? formatCurrency(holding.current_price) : t("crypto.pendingPrice")}
+ {holding.current_price !== null
+ ? maskAmount(formatCurrency(holding.current_price), hidden)
+ : t("crypto.pendingPrice")}
|
@@ -88,21 +91,20 @@ export function CryptoHoldingsTable({ items, onTrade, onViewHistory, onDelete }:
|
- {holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")}
+ {holding.value !== null ? maskAmount(formatCurrency(holding.value), hidden) : t("crypto.pendingPrice")}
- {Number(holding.quantity)} {holding.symbol}
+ {maskAmount(`${Number(holding.quantity)} ${holding.symbol}`, hidden)}
|
- {holding.avg_buy_price !== null ? formatCurrency(holding.avg_buy_price) : "—"}
+ {holding.avg_buy_price !== null ? maskAmount(formatCurrency(holding.avg_buy_price), hidden) : "—"}
|
{holding.profit_loss !== null && holding.profit_loss_percent !== null ? (
<>
- {sign}
- {formatCurrency(holding.profit_loss)}
+ {maskAmount(`${sign}${formatCurrency(holding.profit_loss)}`, hidden)}
{sign}
diff --git a/frontend/src/components/crypto/CryptoStatsRow.tsx b/frontend/src/components/crypto/CryptoStatsRow.tsx
index 1ec3599..9d78908 100644
--- a/frontend/src/components/crypto/CryptoStatsRow.tsx
+++ b/frontend/src/components/crypto/CryptoStatsRow.tsx
@@ -1,12 +1,13 @@
import type { ReactNode } from "react";
import { Card, CardContent } from "@/components/ui/Card";
-import { formatCurrency } from "@/lib/format";
+import { formatCurrency, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoStatsRowProps {
holdings: CryptoHolding[];
isLoading: boolean;
+ hidden: boolean;
}
interface Totals {
@@ -79,7 +80,7 @@ function PerformerTile({ label, holding }: { label: string; holding: CryptoHoldi
);
}
-export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) {
+export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowProps) {
const { t } = useTranslation();
if (isLoading) {
@@ -94,8 +95,7 @@ export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) {
- {totals.totalProfitLoss >= 0 ? "+" : ""}
- {formatCurrency(totals.totalProfitLoss)}
+ {maskAmount(`${totals.totalProfitLoss >= 0 ? "+" : ""}${formatCurrency(totals.totalProfitLoss)}`, hidden)}
{totals.totalProfitLossPercent !== null && (
@@ -106,7 +106,9 @@ export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) {
- {formatCurrency(totals.totalCostBasis)}
+
+ {maskAmount(formatCurrency(totals.totalCostBasis), hidden)}
+
{t("crypto.stats.costBasisHint")}
diff --git a/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx b/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx
index 40c8e97..2304b49 100644
--- a/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx
+++ b/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx
@@ -1,7 +1,7 @@
import { Trash2 } from "lucide-react";
import { Dialog } from "@/components/ui/Dialog";
import { useCryptoTransactions, useDeleteCryptoTransaction } from "@/hooks/useCrypto";
-import { formatCurrency, formatTransactionDate } from "@/lib/format";
+import { formatCurrency, formatTransactionDate, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
@@ -9,9 +9,10 @@ interface CryptoTransactionHistoryModalProps {
open: boolean;
onClose: () => void;
holding: CryptoHolding | null;
+ hidden: boolean;
}
-export function CryptoTransactionHistoryModal({ open, onClose, holding }: CryptoTransactionHistoryModalProps) {
+export function CryptoTransactionHistoryModal({ open, onClose, holding, hidden }: CryptoTransactionHistoryModalProps) {
const { t } = useTranslation();
const { data: transactions, isLoading } = useCryptoTransactions(holding ? holding.asset_id : null);
const deleteTransaction = useDeleteCryptoTransaction();
@@ -45,7 +46,7 @@ export function CryptoTransactionHistoryModal({ open, onClose, holding }: Crypto
- {Number(tx.quantity)} {holding.symbol} · {formatCurrency(tx.price_per_unit)}
+ {maskAmount(`${Number(tx.quantity)} ${holding.symbol} · ${formatCurrency(tx.price_per_unit)}`, hidden)}
{formatTransactionDate(tx.date, true)}
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 7e7f0c0..666e95b 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -23,6 +23,15 @@ export function formatSignedCurrency(amount: number | string, currency: string =
return `${sign}${formatCurrency(value, currency)}`;
}
+/** "Hide balance" masking — wraps an already-formatted amount (currency,
+ * quantity, whatever reveals a dollar figure) rather than reformatting it,
+ * so call sites don't need a separate hidden-vs-shown branch for every
+ * number. Percentages are deliberately never masked by callers — a % move
+ * doesn't reveal how much money is actually involved. */
+export function maskAmount(formatted: string, hidden: boolean): string {
+ return hidden ? "••••" : formatted;
+}
+
const MONTH_LABELS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
const MONTH_LABELS_RU = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"] as const;
diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx
index c5d934b..98bce12 100644
--- a/frontend/src/pages/CryptoPage.tsx
+++ b/frontend/src/pages/CryptoPage.tsx
@@ -21,6 +21,11 @@ function formatSyncedAt(iso: string): string {
export function CryptoPage() {
const { t } = useTranslation();
const [range, setRange] = useState("30d");
+ // 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
+ // component happened to own it.
+ const [hidden, setHidden] = useState(false);
const { data, isLoading } = useCryptoHoldings();
const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range);
const refresh = useRefreshCryptoPrices();
@@ -40,11 +45,18 @@ export function CryptoPage() {
return (
-
+ setHidden((prev) => !prev)}
+ />
-
+
-
+
@@ -75,6 +87,7 @@ export function CryptoPage() {
{data?.error_key && {t(`crypto.syncError.${data.error_key}`)} }
setHistoryHolding(null)}
holding={historyHolding}
+ hidden={hidden}
/>
);
|