Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 50 additions & 28 deletions app/mining/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
XAxis, YAxis, CartesianGrid, ResponsiveContainer, Legend,
} from 'recharts';
import { ChartTooltip as Tooltip } from '@/components/charts/ChartTooltip';
import { getApiUrl } from '@/lib/api-config';
import { getApiUrl, NETWORK } from '@/lib/api-config';
import { useTheme } from '@/contexts/ThemeContext';
import { categoryColor } from '@/lib/category-colors';
import { getChartColors } from '@/lib/chart-theme';
Expand Down Expand Up @@ -119,7 +119,7 @@ function PeriodSelector({ value, onChange }: { value: Period; onChange: (p: Peri

function DistributionSection() {
const { theme } = useTheme();
const [period, setPeriod] = useState<Period>('7d');
const [period, setPeriod] = useState<Period>('30d');
const [data, setData] = useState<PoolDist[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -169,7 +169,7 @@ function DistributionSection() {

function RankingSection() {
const { theme } = useTheme();
const [period, setPeriod] = useState<Period>('7d');
const [period, setPeriod] = useState<Period>('30d');
const [ranking, setRanking] = useState<PoolRank[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -316,7 +316,7 @@ function ChartModeToggle({ mode, onChange }: { mode: ChartMode; onChange: (m: Ch
function HashrateShareSection() {
const { theme } = useTheme();
const colors = getChartColors(theme);
const [period, setPeriod] = useState<Period>('all');
const [period, setPeriod] = useState<Period>('30d');
const [chartMode, setChartMode] = useState<ChartMode>('line');
const [series, setSeries] = useState<HashratePoint[]>([]);
const [allPools, setAllPools] = useState<string[]>([]);
Expand Down Expand Up @@ -514,25 +514,41 @@ function HashrateShareSection() {
function MinerBehaviorSection() {
const { theme } = useTheme();
const colors = getChartColors(theme);
const [period, setPeriod] = useState<Period>('all');
const [period, setPeriod] = useState<Period>('30d');
const [series, setSeries] = useState<BehaviorPoint[]>([]);
const [summary, setSummary] = useState<BehaviorSummary | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [coverage, setCoverage] = useState<{ firstDate: string; lastDate: string; refreshedAt: string | null; freshness: 'current' | 'stale' | 'unknown' } | null>(null);
const [loading, setLoading] = useState(true);
const currency = NETWORK === 'mainnet' ? 'ZEC' : 'TAZ';

useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(`${getApiUrl()}/v1/mining/miner-behavior?period=${period}`)
.then(r => r.ok ? readApiData(r) : null)
.then(res => {
if (res) {
setSeries(res.series || []);
setSummary(res.summary || null);
setMessage(res.message || null);
}
setLoading(false);
setSeries([]);
setSummary(null);
setMessage(null);
setCoverage(null);
fetch(`${getApiUrl()}/v1/mining/miner-behavior?period=${period}`, { signal: controller.signal })
.then(async response => {
if (!response.ok) throw new Error('Miner behavior unavailable');
return readApiData(response);
})
.catch(() => setLoading(false));
.then(data => {
if (controller.signal.aborted) return;
if (!data || !Array.isArray(data.series)) throw new Error('Invalid miner behavior response');
setSeries(data.series);
setSummary(data.series.length ? data.summary || null : null);
setCoverage(data.coverage || null);
setMessage(data.series.length ? null : data.message || 'No miner reward spending snapshots are available for this period.');
})
.catch(() => {
if (!controller.signal.aborted) setMessage('Miner behavior data is temporarily unavailable. Try again later.');
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [period]);

const chartData = series.map(p => ({
Expand Down Expand Up @@ -560,8 +576,8 @@ function MinerBehaviorSection() {
</Link>
</div>
<p className="text-xs text-secondary mt-1 font-sans">
How much of their block rewards miners move vs leave unspent. A high moved ratio means rewards changed address
quickly; it does not prove they were sold. The{' '}
Spending of attributed miner payout outputs, grouped by the date the reward was earned. A movement means
an output was spent; it does not prove a sale. Amounts reflect current indexed spending when refreshed, rather than spending on the reward date. Unattributed payouts are excluded. The{' '}
<Link href="/zodl" className="text-cipher-gold hover:underline">ZODL leaderboard</Link> breaks each pool&apos;s spending down by destination (shielded vs. exchange vs. transparent), showing observed transfers rather than proving sales.
</p>
</div>
Expand All @@ -572,29 +588,29 @@ function MinerBehaviorSection() {
<CardBody className="py-3">
<p className="text-caption font-mono text-muted uppercase mb-1">Total Earned</p>
<p className="text-lg font-semibold font-mono tabular-nums text-primary">
{formatZec(summary.totalEarnedZat)} ZEC
{formatZec(summary.totalEarnedZat)} {currency}
</p>
</CardBody>
</Card>
<Card>
<CardBody className="py-3">
<p className="text-caption font-mono text-muted uppercase mb-1">Total Moved</p>
<p className="text-lg font-semibold font-mono tabular-nums text-cipher-orange">
{formatZec(summary.totalSpentZat)} ZEC
{formatZec(summary.totalSpentZat)} {currency}
</p>
</CardBody>
</Card>
<Card>
<CardBody className="py-3">
<p className="text-caption font-mono text-muted uppercase mb-1">Still Held</p>
<p className="text-lg font-semibold font-mono tabular-nums text-cipher-green">
{formatZec(summary.totalHeldZat)} ZEC
{formatZec(summary.totalHeldZat)} {currency}
</p>
</CardBody>
</Card>
<Card>
<CardBody className="py-3">
<p className="text-caption font-mono text-muted uppercase mb-1">Sell Ratio</p>
<p className="text-caption font-mono text-muted uppercase mb-1">Moved Ratio</p>
<p className="text-lg font-semibold font-mono tabular-nums text-primary">
{(summary.overallSellRatio * 100).toFixed(1)}%
</p>
Expand All @@ -610,11 +626,10 @@ function MinerBehaviorSection() {
>
{loading ? (
<ChartSkeleton height={320} />
) : message ? (
) : message || !chartData.length ? (
<div className="flex items-center justify-center h-[320px]">
<div className="text-center">
<p className="text-sm text-muted font-mono">{message}</p>
<p className="text-caption text-muted mt-2">Run the snapshot job to populate this data.</p>
<p className="text-sm text-muted font-mono">{message || 'No miner reward spending snapshots are available for this period.'}</p>
</div>
</div>
) : (
Expand All @@ -634,7 +649,7 @@ function MinerBehaviorSection() {
stroke={colors.axis}
tick={{ fill: colors.axis, fontSize: 12 }}
tickFormatter={(v: number) => `${v.toFixed(0)}`}
label={{ value: 'ZEC', angle: -90, position: 'insideLeft', fill: colors.axis, fontSize: 12 }}
label={{ value: currency, angle: -90, position: 'insideLeft', fill: colors.axis, fontSize: 12 }}
/>
<Tooltip
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
Expand All @@ -648,14 +663,14 @@ function MinerBehaviorSection() {
itemStyle={{ color: colors.tooltipText }}
labelStyle={{ color: colors.tooltipText, marginBottom: 4 }}
formatter={(value, name) => {
const label = name === 'earned' ? 'Earned' : name === 'spent' ? 'Moved/Sold' : 'Held';
return [`${Number(value).toFixed(2)} ZEC`, label];
const label = name === 'earned' ? 'Earned' : name === 'spent' ? 'Moved' : 'Held';
return [`${Number(value).toFixed(2)} ${currency}`, label];
}}
labelFormatter={(label) => String(label)}
/>
<Legend
wrapperStyle={{ fontSize: 12, fontFamily: 'var(--font-geist-mono), monospace' }}
formatter={(value) => value === 'earned' ? 'Earned' : value === 'spent' ? 'Moved/Sold' : 'Held'}
formatter={(value) => value === 'earned' ? 'Earned' : value === 'spent' ? 'Moved' : 'Held'}
/>
<Bar dataKey="earned" fill={colors.gold} fillOpacity={0.3} stroke={colors.gold} />
<Bar dataKey="spent" fill={colors.deshielding} fillOpacity={0.7} />
Expand All @@ -664,6 +679,13 @@ function MinerBehaviorSection() {
</ResponsiveContainer>
)}
</ChartCard>
{coverage && series.length > 0 && (
<p className="text-caption text-muted mt-3">
Available reward dates: {coverage.firstDate}–{coverage.lastDate}.
{coverage.refreshedAt && ` Oldest snapshot refresh: ${new Date(coverage.refreshedAt).toLocaleString(undefined, { timeZone: 'UTC' })} UTC.`}
{coverage.freshness === 'stale' && ' Some snapshots are more than 48 hours old.'}
</p>
)}
</section>
);
}
Expand Down
2 changes: 1 addition & 1 deletion components/mining/MiningSoftwareSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function MiningSoftwareSection() {
const { theme } = useTheme();
const colors = getChartColors(theme);
const palette = getMiningSoftwareColors(theme);
const [period, setPeriod] = useState("all");
const [period, setPeriod] = useState("30d");
const [range, setRange] = useState<{ from: string; to: string } | null>(null);
const [bucket, setBucket] = useState("auto");
const [data, setData] = useState<History | null>(null);
Expand Down
2 changes: 1 addition & 1 deletion components/mining/OrphanRateSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function utc(value: string) {
}

export function OrphanRateSection() {
const [period, setPeriod] = useState<Period>('7d');
const [period, setPeriod] = useState<Period>('30d');
const { data, loading, error } = useApiQuery<OrphanRates>('/v1/mining/orphan-rates', undefined, { refreshInterval: 60_000 });
const supported = data?.method === 'observed-indexer-reorg-v1' || data?.method === 'observed-block-capture-v2';
const selected = supported ? data.periods.find(window => window.period === period) : null;
Expand Down
23 changes: 17 additions & 6 deletions components/network/MiningMetricsChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTheme } from '@/contexts/ThemeContext';
import { getChartColors } from '@/lib/chart-theme';
import { formatDifficulty, formatHashrate } from '@/lib/format-numbers';
import { useApiQuery } from '@/hooks/useApiQuery';
import { CURRENCY } from '@/lib/config';
import { ChartCard } from './ChartCard';

type MetricKey = 'solrate' | 'difficulty' | 'blockTime' | 'txFees' | 'txCount';
Expand All @@ -17,14 +18,15 @@ const METRICS: { key: MetricKey; label: string; color: string; format: (v: numbe
{ key: 'solrate', label: 'Solrate', color: 'gold', format: (v) => formatHashrate(v) },
{ key: 'difficulty', label: 'Difficulty', color: 'yellow', format: (v) => formatDifficulty(v) },
{ key: 'blockTime', label: 'Block time', color: 'green', format: (v) => `~${Math.round(v)}s` },
{ key: 'txFees', label: 'TX fees', color: 'purple', format: (v) => `${v.toFixed(6)} ZEC` },
{ key: 'txFees', label: 'TX fees', color: 'purple', format: (v) => `${v.toFixed(6)} ${CURRENCY}` },
{ key: 'txCount', label: 'TX count', color: 'gold', format: (v) => v.toFixed(1) },
];

interface MiningMetricsData {
points?: ({ height: number } & Record<MetricKey, number | null>)[];
latest?: Partial<Record<MetricKey, number | null>>;
window?: number;
solrateMethod?: string;
}

export function MiningMetricsChart() {
Expand All @@ -33,7 +35,7 @@ export function MiningMetricsChart() {
const [active, setActive] = useState<MetricKey>('solrate');
const window = 20;

const { data } = useApiQuery<MiningMetricsData>(
const { data, loading, error } = useApiQuery<MiningMetricsData>(
'/v1/mining/metrics',
{ window, limit: 120 },
);
Expand All @@ -42,6 +44,11 @@ export function MiningMetricsChart() {

const metric = METRICS.find((m) => m.key === active)!;
const stroke = metric.color === 'gold' ? colors.gold : metric.color === 'yellow' ? colors.yellow : metric.color === 'green' ? colors.orchard : colors.purple;
const hasValues = points.some((point) => point[active] != null && Number.isFinite(point[active]));
const elapsedSolrate = active === 'solrate' && data?.solrateMethod === 'difficulty-work-over-elapsed-v1';
const emptyMessage = loading ? 'Loading mining metrics…'
: error ? 'Mining metrics are temporarily unavailable.'
: `No complete ${window}-block window is available for ${metric.label.toLowerCase()}.`;

return (
<div className="space-y-4">
Expand All @@ -60,14 +67,18 @@ export function MiningMetricsChart() {
<p className="text-sm font-semibold font-mono text-primary whitespace-nowrap truncate">
{value != null ? m.format(value) : '—'}
</p>
<p className="text-caption text-muted font-mono mt-0.5">{window} blk avg</p>
<p className="text-caption text-muted font-mono mt-0.5">{window} blk {m.key === 'solrate' && data?.solrateMethod === 'difficulty-work-over-elapsed-v1' ? 'estimate' : 'avg'}</p>
</button>
);
})}
</div>

<ChartCard title={`${metric.label.toUpperCase().replace(/\s+/g, '_')}_TREND`} height={280} watermarkSize="lg">
<p className="text-caption text-muted mb-3">{window}-block trailing averages. Timing uses block-header timestamps; unavailable values remain gaps.</p>
<p className="text-caption text-muted mb-3">{elapsedSolrate
? `${window}-block estimates use total difficulty and elapsed header time; unavailable values remain gaps.`
: `${window}-block trailing averages. Timing uses block-header timestamps; unavailable values remain gaps.`}</p>
{error && hasValues && <p role="status" className="text-caption text-muted mb-3">Refresh unavailable. Showing the last received mining metrics.</p>}
{!hasValues ? <div role="status" className="flex h-[280px] items-center justify-center text-caption text-muted">{emptyMessage}</div> :
<ResponsiveContainer initialDimension={{ width: 500, height: 300 }} width="100%" height={280}>
<LineChart data={points}>
<CartesianGrid strokeDasharray="2 6" stroke={colors.grid} opacity={0.5} />
Expand All @@ -93,9 +104,9 @@ export function MiningMetricsChart() {
labelFormatter={(h) => `Block ${h}`}
formatter={(value) => [value == null ? 'Unavailable' : metric.format(Number(value)), metric.label]}
/>
<Line type="monotone" dataKey={active} stroke={stroke} strokeWidth={2} dot={false} connectNulls={false} />
<Line type="linear" dataKey={active} stroke={stroke} strokeWidth={2} dot={{ r: 2, fill: stroke, strokeWidth: 0 }} connectNulls={false} />
</LineChart>
</ResponsiveContainer>
</ResponsiveContainer>}
</ChartCard>
</div>
);
Expand Down
Loading
Loading