diff --git a/api/internal/state/aggregator.go b/api/internal/state/aggregator.go
index b4447b8..5db4bbc 100644
--- a/api/internal/state/aggregator.go
+++ b/api/internal/state/aggregator.go
@@ -340,9 +340,11 @@ func (a *Aggregator) refresh(ctx context.Context) {
copy(next.HashrateHistory, a.hrHistory)
}
- // --- next-block reward (at most once per minute) ---
+ // --- next-block reward (refreshed every ~15s so users see the
+ // fee component tick up as mempool grows; bitcoind caches the
+ // template internally, so the RPC is cheap even at this cadence).
next.NextBlockRewardBTC = a.nextBlockReward
- needTemplate := now.Sub(a.lastTemplateFetch) >= time.Minute
+ needTemplate := now.Sub(a.lastTemplateFetch) >= 15*time.Second
a.mu.Unlock()
if needTemplate && a.RPC != nil && next.BitcoinOK {
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index ec4fb4d..33d1568 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -7,6 +7,8 @@
import MinersTable from "./lib/MinersTable.svelte";
import BlocksTable from "./lib/BlocksTable.svelte";
import BestShares from "./lib/BestShares.svelte";
+ import UserDetailModal from "./lib/UserDetailModal.svelte";
+ import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
onMount(() => {
connect();
@@ -27,14 +29,15 @@
{:else}
-
-
-
-
+
+
{/if}
+
+
+
diff --git a/ui/src/lib/HashrateChart.svelte b/ui/src/lib/HashrateChart.svelte
index 8f4501d..dfc63e2 100644
--- a/ui/src/lib/HashrateChart.svelte
+++ b/ui/src/lib/HashrateChart.svelte
@@ -8,7 +8,32 @@
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
- const points = $derived(snap.data?.hashrate_history ?? []);
+ const rawPoints = $derived(snap.data?.hashrate_history ?? []);
+
+ // Bucket raw per-minute samples into ~96 time buckets for a readable
+ // 24h chart. Averaging each bucket smooths out short-term jitter
+ // without losing the trend. When we have fewer raw points than the
+ // target (early in the process's life), just pass them through.
+ const TARGET_POINTS = 96;
+ const points = $derived.by(() => {
+ if (rawPoints.length <= TARGET_POINTS) return rawPoints;
+ const bucketSize = Math.ceil(rawPoints.length / TARGET_POINTS);
+ const out: { t: number; v: number }[] = [];
+ for (let i = 0; i < rawPoints.length; i += bucketSize) {
+ let sum = 0;
+ let count = 0;
+ let tSum = 0;
+ for (let j = i; j < Math.min(i + bucketSize, rawPoints.length); j++) {
+ sum += rawPoints[j].v;
+ tSum += rawPoints[j].t;
+ count++;
+ }
+ if (count > 0) {
+ out.push({ t: Math.round(tSum / count), v: sum / count });
+ }
+ }
+ return out;
+ });
const chart = $derived.by(() => {
if (points.length < 2) return null;
diff --git a/ui/src/lib/MinersTable.svelte b/ui/src/lib/MinersTable.svelte
index c4441d9..6b293e2 100644
--- a/ui/src/lib/MinersTable.svelte
+++ b/ui/src/lib/MinersTable.svelte
@@ -1,5 +1,6 @@
+
+
+
+{#if address}
+
+
+
+
+
+
+
+
+
Hashrate
+
{formatHashrate(totals.hs1m)}
+
+ {#if totals.hs5m > 0}5m {formatHashrate(totals.hs5m)} · {/if}
+ 1h {formatHashrate(totals.hs1h)}
+ {#if totals.hs24h > 0} · 24h {formatHashrate(totals.hs24h)}{/if}
+
+
+
+
Best share
+
{formatDifficulty(totals.bestEver || totals.bestDiff)}
+
round {formatDifficulty(totals.bestDiff)}
+
+
+
Workers
+
{totals.workers}
+
last share {formatAgo(totals.lastShare)}
+
+
+
+
+ Workers
+ {#if workerRows.length === 0}
+ No worker data yet for this address.
+ {:else}
+
+
+
+
+ | Worker |
+ Hardware |
+ Hashrate (1m) |
+ Hashrate (1h) |
+ Diff |
+ Best (round) |
+ Best (ever) |
+ Last share |
+
+
+
+ {#each workerRows as r (r.worker)}
+
+ | {r.worker} |
+ {r.hardware} |
+ {formatHashrate(r.hashrate1m)} |
+ {formatHashrate(r.hashrate1h)} |
+ {formatDifficulty(r.diff)} |
+ {formatDifficulty(r.bestRound)} |
+ {formatDifficulty(r.bestEver)} |
+ {formatAgo(r.lastShare)} |
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+{/if}
+
+
diff --git a/ui/src/stores/selection.svelte.ts b/ui/src/stores/selection.svelte.ts
new file mode 100644
index 0000000..de18a09
--- /dev/null
+++ b/ui/src/stores/selection.svelte.ts
@@ -0,0 +1,13 @@
+// Cross-component selection state. Currently just tracks the user
+// whose detail modal is open; App renders the modal off this store
+// so any component in the tree can open it by setting `user`.
+
+export const selection = $state<{ user: string | null }>({ user: null });
+
+export function selectUser(address: string): void {
+ selection.user = address;
+}
+
+export function clearSelection(): void {
+ selection.user = null;
+}