fix: "hide balance" on Crypto tab only masked the chart's header number

The eye toggle lived as local state inside CryptoHistoryChart, so it only
masked the big total figure — the holdings table, allocation card, and
stats tiles below stayed fully visible. Lifted `hidden` to CryptoPage and
threaded it through every money-displaying section (chart, stats row,
allocation list, holdings table, trade history modal) via a shared
maskAmount() helper.

Percentages (1h/24h/7d%, profit/loss %) stay visible on purpose — a
relative change doesn't reveal the actual amount of money involved, only
absolute currency figures and coin quantities (which combined with a
visible price also reveal an amount) are masked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
ZProger
2026-08-30 04:43:30 +03:00
parent 4ea83a9cda
commit 5f34d0a612
7 changed files with 80 additions and 35 deletions

View File

@@ -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)}%
</span>
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
{formatCurrency(slice.amount)}
{maskAmount(formatCurrency(slice.amount), hidden)}
</span>
</li>
);

View File

@@ -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 (
<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">{formatCurrency(point.value)}</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 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 }:
<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 ? "…" : hidden ? "••••••" : formatCurrency(history?.current ?? 0)}
{isLoading ? "…" : maskAmount(formatCurrency(history?.current ?? 0), hidden)}
<button
type="button"
aria-label={hidden ? t("crypto.chart.show") : t("crypto.chart.hide")}
onClick={() => setHidden((prev) => !prev)}
onClick={onToggleHidden}
className="text-text-muted hover:text-text-primary"
>
{hidden ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</p>
{history && !hidden && (
{history && (
<p className="mt-1 flex items-center gap-1 text-sm font-medium" style={{ color: trendColor }}>
{isPositive ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
{formatSignedCurrency(history.change_amount)}
{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>
@@ -82,7 +98,7 @@ export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }:
</linearGradient>
</defs>
<YAxis hide domain={["auto", "auto"]} />
<Tooltip content={<ChartTooltip />} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
<Tooltip content={<ChartTooltip hidden={hidden} />} cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} />
<Area
type="monotone"
dataKey="value"

View File

@@ -1,10 +1,11 @@
import { Bitcoin, History, Plus, Trash2 } from "lucide-react";
import { formatCurrency } from "@/lib/format";
import { formatCurrency, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding } from "@/types";
interface CryptoHoldingsTableProps {
items: CryptoHolding[];
hidden: boolean;
onTrade: (holding: CryptoHolding) => 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 }:
</div>
</td>
<td className="py-3 pr-3 text-right tabular-nums text-text-primary">
{holding.current_price !== null ? formatCurrency(holding.current_price) : t("crypto.pendingPrice")}
{holding.current_price !== null
? maskAmount(formatCurrency(holding.current_price), hidden)
: t("crypto.pendingPrice")}
</td>
<td className="py-3 pr-3 text-right">
<PercentCell value={holding.price_change_1h} />
@@ -88,21 +91,20 @@ export function CryptoHoldingsTable({ items, onTrade, onViewHistory, onDelete }:
</td>
<td className="py-3 pr-3 text-right">
<span className="block tabular-nums text-text-primary">
{holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")}
{holding.value !== null ? maskAmount(formatCurrency(holding.value), hidden) : t("crypto.pendingPrice")}
</span>
<span className="block text-xs tabular-nums text-text-muted">
{Number(holding.quantity)} {holding.symbol}
{maskAmount(`${Number(holding.quantity)} ${holding.symbol}`, hidden)}
</span>
</td>
<td className="py-3 pr-3 text-right tabular-nums text-text-primary">
{holding.avg_buy_price !== null ? formatCurrency(holding.avg_buy_price) : "—"}
{holding.avg_buy_price !== null ? maskAmount(formatCurrency(holding.avg_buy_price), hidden) : "—"}
</td>
<td className="py-3 pr-3 text-right">
{holding.profit_loss !== null && holding.profit_loss_percent !== null ? (
<>
<span className="block tabular-nums" style={{ color: profitColor }}>
{sign}
{formatCurrency(holding.profit_loss)}
{maskAmount(`${sign}${formatCurrency(holding.profit_loss)}`, hidden)}
</span>
<span className="block text-xs tabular-nums" style={{ color: profitColor }}>
{sign}

View File

@@ -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) {
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatTile label={t("crypto.stats.allTimeProfit")}>
<p className="text-lg font-semibold tabular-nums" style={{ color: profitColor }}>
{totals.totalProfitLoss >= 0 ? "+" : ""}
{formatCurrency(totals.totalProfitLoss)}
{maskAmount(`${totals.totalProfitLoss >= 0 ? "+" : ""}${formatCurrency(totals.totalProfitLoss)}`, hidden)}
</p>
{totals.totalProfitLossPercent !== null && (
<p className="mt-0.5 text-sm font-medium tabular-nums" style={{ color: profitColor }}>
@@ -106,7 +106,9 @@ export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) {
</StatTile>
<StatTile label={t("crypto.stats.costBasis")}>
<p className="text-lg font-semibold tabular-nums text-text-primary">{formatCurrency(totals.totalCostBasis)}</p>
<p className="text-lg font-semibold tabular-nums text-text-primary">
{maskAmount(formatCurrency(totals.totalCostBasis), hidden)}
</p>
<p className="mt-0.5 text-xs text-text-muted">{t("crypto.stats.costBasisHint")}</p>
</StatTile>

View File

@@ -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
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm text-text-primary">
{Number(tx.quantity)} {holding.symbol} · {formatCurrency(tx.price_per_unit)}
{maskAmount(`${Number(tx.quantity)} ${holding.symbol} · ${formatCurrency(tx.price_per_unit)}`, hidden)}
</span>
<span className="block text-xs text-text-muted">
{formatTransactionDate(tx.date, true)}

View File

@@ -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;

View File

@@ -21,6 +21,11 @@ function formatSyncedAt(iso: string): string {
export function CryptoPage() {
const { t } = useTranslation();
const [range, setRange] = useState<CryptoRange>("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 (
<div className="space-y-5">
<CryptoHistoryChart history={history} isLoading={isHistoryLoading} range={range} onRangeChange={setRange} />
<CryptoHistoryChart
history={history}
isLoading={isHistoryLoading}
range={range}
onRangeChange={setRange}
hidden={hidden}
onToggleHidden={() => setHidden((prev) => !prev)}
/>
<CryptoStatsRow holdings={holdings} isLoading={isLoading} />
<CryptoStatsRow holdings={holdings} isLoading={isLoading} hidden={hidden} />
<CryptoAllocationCard holdings={holdings} isLoading={isLoading} />
<CryptoAllocationCard holdings={holdings} isLoading={isLoading} hidden={hidden} />
<Card>
<CardHeader className="flex-col items-stretch gap-3 sm:flex-row sm:items-center">
@@ -75,6 +87,7 @@ export function CryptoPage() {
{data?.error_key && <p className="mb-3 text-sm text-danger">{t(`crypto.syncError.${data.error_key}`)}</p>}
<CryptoHoldingsTable
items={holdings}
hidden={hidden}
onTrade={setTradingHolding}
onViewHistory={setHistoryHolding}
onDelete={handleDelete}
@@ -90,6 +103,7 @@ export function CryptoPage() {
open={historyHolding !== null}
onClose={() => setHistoryHolding(null)}
holding={historyHolding}
hidden={hidden}
/>
</div>
);