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:
1 parent
467fe5e2ef
commit
f1dc0a8a79
6 files changed
+392
-30
No files matched your search
+203
-14
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user