Persist best share glow, share animations, and UI polish

Backend:
- Persist best_diff as a high-water mark in kv store so it survives
  restarts and transient ckpool gaps
- Persist acked_best_diff so the "new best share" glow survives pool
  restarts and works across multiple new bests before dismissal
- Add POST /api/admin/ack-best and /api/admin/reset-ack-best endpoints

Frontend:
- Best share card: golden glow, shimmer, sparkles, "NEW BEST SHARE!"
  tag with previous value, "Nice" dismiss button
- Optimistic local override so Nice/test buttons respond instantly
  instead of waiting for next WebSocket push
- Fix shimmer sweep to go left-to-right (not start from middle)
- Share animations: reject (red flash + shake) suppresses accept;
  reject effect declared first so guard reads correct state
- Share tooltips show exact counts on hover
- Block reward tooltip shows full amount in satoshis
- Expected block uses 1h hashrate instead of 1m
- formatDuration uses full unit names; years always show 1 decimal
- formatWork always shows 2 decimal places
- Map chain "main" to "mainnet" in block height card
This commit is contained in:
satoshi committed 2026-05-12 21:56:02 +03:00
1 parent 467fe5e2ef
commit f1dc0a8a79
6 files changed
+392 -30

No files matched your search

+203 -14
View File
@@ -21,7 +21,7 @@
const expected = $derived.by(() => {
const diff = data.chain?.difficulty ?? 0;
return expectedBlockSeconds(data.hashrate_hs_1m, data.network_hashrate_hs, diff);
return expectedBlockSeconds(data.hashrate_hs_1h, data.network_hashrate_hs, diff);
});
// Round effort: share of the expected work-to-find-a-block that the
@@ -86,6 +86,40 @@
}
prevHeight = h;
});
// New best share glow — derived from the persisted acked_best_diff.
// Glows whenever best_diff > acked_best_diff. Survives restarts and
// works even if multiple new bests are found before the user opens the UI.
//
// Local override: buttons set localAcked immediately so the UI reacts
// in the same frame. The override holds until the server's snapshot
// reflects the change (next WS push after the POST is processed).
let localAcked = $state<number | null>(null);
const effectiveAcked = $derived(
localAcked !== null ? localAcked : (data.acked_best_diff ?? 0),
);
$effect(() => {
const server = data.acked_best_diff ?? 0;
if (localAcked !== null && server === localAcked) {
localAcked = null;
}
});
const bestGlow = $derived(
(data.best_diff ?? 0) > 0 && (data.best_diff ?? 0) > effectiveAcked,
);
const bestPrevValue = $derived(effectiveAcked);
function ackBest(): void {
localAcked = data.best_diff ?? 0;
fetch("/api/admin/ack-best", { method: "POST" });
}
// DEBUG: remove before shipping
function debugBest(): void {
localAcked = 0;
fetch("/api/admin/reset-ack-best", { method: "POST" });
}
</script>
<section class="grid-5">
@@ -99,17 +133,42 @@
</div>
</div>
<div class="card">
<div class="stat-label">Best share</div>
<div class="stat-value">{formatDifficulty(data.best_diff)}</div>
<div
class="luck-sub"
class:lucky={luckPct > 100}
class:unlucky={luckPct > 0 && luckPct < 100}
title="Luck = best share / total work. 100% means your best share matches the work done. Above 100% is lucky (found a harder share than expected), below is unlucky."
>
<span class="luck-value">{luckLabel}</span>
<span class="luck-label">luck</span>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="card best-card"
class:best-glow={bestGlow}
>
{#if bestGlow}
<div class="best-tag">New best share! Previous: {formatDifficulty(bestPrevValue)}</div>
{/if}
<div class="shimmer" aria-hidden="true"></div>
<div class="sparkles" aria-hidden="true">
{#each {length: 6} as _, i}
<span class="spark" style="--i:{i}"></span>
{/each}
</div>
<div class="best-inner">
<div class="stat-label">
Best share
<!-- DEBUG: remove before shipping -->
<button class="debug-btn" onclick={(e: MouseEvent) => { e.stopPropagation(); debugBest(); }}>test</button>
</div>
<div class="stat-value">{formatDifficulty(data.best_diff)}</div>
<div
class="luck-sub"
class:lucky={luckPct > 100}
class:unlucky={luckPct > 0 && luckPct < 100}
title="Luck = best share / total work. 100% means your best share matches the work done. Above 100% is lucky (found a harder share than expected), below is unlucky."
>
<span class="luck-value">{luckLabel}</span>
<span class="luck-label">luck</span>
</div>
{#if bestGlow}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ack-btn" onclick={ackBest} title="Dismiss the new best share animation">Nice</div>
{/if}
</div>
</div>
@@ -147,12 +206,12 @@
<div class="card height-card" class:new-block={heightFlash}>
<div class="stat-label">Block height</div>
<div class="stat-value">{height ? height.toLocaleString() : "—"}</div>
<div class="stat-sub">{data.chain?.chain ?? "—"}</div>
<div class="stat-sub">{data.chain?.chain === "main" ? "mainnet" : data.chain?.chain ?? "—"}</div>
</div>
<div class="card">
<div class="stat-label">Block reward</div>
<div class="stat-value">
<div class="stat-value" title="{data.next_block_reward_btc ? Math.round(data.next_block_reward_btc * 1e8).toLocaleString() + ' sats' : ''}">
{data.next_block_reward_btc ? data.next_block_reward_btc.toFixed(4) : "—"}
<span class="unit">BTC</span>
</div>
@@ -269,6 +328,136 @@
text-shadow: 0 0 10px rgba(255, 107, 107, 0.25);
}
/* Best share persistent glow */
.best-card {
position: relative;
overflow: hidden;
transition: border-color 0.4s, box-shadow 0.4s;
}
.best-inner {
position: relative;
z-index: 1;
}
.shimmer {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: linear-gradient(
105deg,
transparent 20%,
rgba(245, 196, 71, 0.08) 35%,
rgba(255, 220, 80, 0.28) 50%,
rgba(245, 196, 71, 0.08) 65%,
transparent 80%
);
}
.best-card.best-glow {
padding-top: calc(1.25rem + 1.4em);
border-color: rgba(245, 196, 71, 0.7);
box-shadow:
0 0 20px rgba(245, 196, 71, 0.35),
0 0 40px rgba(245, 196, 71, 0.12),
inset 0 0 12px rgba(245, 196, 71, 0.06);
animation: best-border-pulse 2s ease-in-out infinite alternate;
}
@keyframes best-border-pulse {
0% { box-shadow: 0 0 16px rgba(245, 196, 71, 0.25), 0 0 30px rgba(245, 196, 71, 0.08); }
100% { box-shadow: 0 0 24px rgba(245, 196, 71, 0.45), 0 0 50px rgba(245, 196, 71, 0.15); }
}
.best-card.best-glow .shimmer {
opacity: 1;
animation: best-shimmer 2s ease-in-out infinite;
}
@keyframes best-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* Floating sparkle dots */
.sparkles {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
}
.best-card.best-glow .sparkles {
opacity: 1;
}
.spark {
position: absolute;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 220, 100, 0.9);
box-shadow: 0 0 6px rgba(255, 220, 100, 0.6);
animation: sparkle-float 3s ease-in-out infinite;
animation-delay: calc(var(--i) * 0.5s);
}
.spark:nth-child(1) { left: 12%; top: 20%; }
.spark:nth-child(2) { left: 85%; top: 35%; }
.spark:nth-child(3) { left: 45%; top: 80%; }
.spark:nth-child(4) { left: 70%; top: 15%; }
.spark:nth-child(5) { left: 25%; top: 65%; }
.spark:nth-child(6) { left: 90%; top: 75%; }
@keyframes sparkle-float {
0%, 100% { opacity: 0; transform: scale(0.5) translateY(0); }
30% { opacity: 1; transform: scale(1.2) translateY(-4px); }
60% { opacity: 0.6; transform: scale(0.8) translateY(2px); }
}
/* "NEW BEST SHARE!" tag */
.best-tag {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
padding: 0.25em 0.6em;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
text-align: center;
color: rgb(30, 22, 5);
background: linear-gradient(90deg, rgba(245, 196, 71, 0.9), rgba(255, 220, 100, 0.95), rgba(245, 196, 71, 0.9));
border-radius: 10px 10px 0 0;
}
/* "Nice" dismiss button */
.ack-btn {
display: block;
width: fit-content;
margin: 0.5em auto 0;
padding: 0.25em 1em;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: rgb(245, 196, 71);
border: 1px solid rgba(245, 196, 71, 0.4);
border-radius: 5px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, transform 0.15s;
}
.ack-btn:hover {
background: rgba(245, 196, 71, 0.15);
border-color: rgba(245, 196, 71, 0.7);
transform: scale(1.05);
}
.debug-btn {
font-size: 0.6rem;
padding: 0.1em 0.4em;
opacity: 0.3;
border-radius: 3px;
margin-left: 0.5em;
vertical-align: middle;
}
.debug-btn:hover {
opacity: 1;
}
.height-card {
transition: box-shadow 0.3s, border-color 0.3s;
}