Merge pull request 'feat: add live indicator and freshness display to LiveStats (#172)' (#179) from fix/issue-172 into master
This commit is contained in:
commit
8a5a4f7e6d
1 changed files with 94 additions and 9 deletions
|
|
@ -1,7 +1,11 @@
|
|||
<template>
|
||||
<div v-if="!error && stats" class="live-stats">
|
||||
<div class="live-header">
|
||||
<span class="live-dot" :class="{ 'live-dot-error': error }"></span>
|
||||
<span class="live-text">Live</span>
|
||||
</div>
|
||||
<div class="stats-grid" :class="{ 'has-floor': showFloorPrice }">
|
||||
<div class="stat-item">
|
||||
<div class="stat-item" :class="{ 'stat-changed': changedStats.has('ethReserve') }">
|
||||
<div class="stat-label">ETH Reserve</div>
|
||||
<div class="stat-value">{{ ethReserveDisplay }}</div>
|
||||
<div v-if="ethReserveSecondary" class="stat-secondary">{{ ethReserveSecondary }}</div>
|
||||
|
|
@ -10,18 +14,18 @@
|
|||
<polyline :points="toSvgPoints(ethReserveSpark)" fill="none" stroke="rgba(96,165,250,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-item" :class="{ 'stat-changed': changedStats.has('ethPerToken') }">
|
||||
<div class="stat-label">ETH / Token</div>
|
||||
<div class="stat-value">{{ ethPerTokenDisplay }}</div>
|
||||
<div v-if="ethPerTokenSecondary" class="stat-secondary">{{ ethPerTokenSecondary }}</div>
|
||||
</div>
|
||||
<div v-if="showFloorPrice" class="stat-item">
|
||||
<div v-if="showFloorPrice" class="stat-item" :class="{ 'stat-changed': changedStats.has('floorPrice') }">
|
||||
<div class="stat-label">Floor Price</div>
|
||||
<div class="stat-value">{{ floorPriceDisplay }}</div>
|
||||
<div v-if="floorPriceSecondary" class="stat-secondary">{{ floorPriceSecondary }}</div>
|
||||
<div v-if="floorDistanceText" class="floor-distance">{{ floorDistanceText }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-item" :class="{ 'stat-changed': changedStats.has('supply') }">
|
||||
<div class="stat-label">Supply (7d)</div>
|
||||
<div class="stat-value">{{ totalSupply }}</div>
|
||||
<div v-if="netSupplyIndicator !== null" class="growth-badge" :class="netSupplyClass">{{ netSupplyIndicator }}</div>
|
||||
|
|
@ -29,7 +33,7 @@
|
|||
<polyline :points="toSvgPoints(supplySpark)" fill="none" stroke="rgba(74,222,128,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-item" :class="{ 'stat-changed': changedStats.has('holders') }">
|
||||
<div class="stat-label">Holders</div>
|
||||
<div class="stat-value">{{ holders }}</div>
|
||||
<div v-if="holderGrowthIndicator !== null" class="growth-badge" :class="holderGrowthClass">{{ holderGrowthIndicator }}</div>
|
||||
|
|
@ -37,12 +41,13 @@
|
|||
<polyline :points="toSvgPoints(holdersSpark)" fill="none" stroke="rgba(251,191,36,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-item" :class="{ 'pulse': isRecentRebalance }">
|
||||
<div class="stat-item" :class="{ 'pulse': isRecentRebalance, 'stat-changed': changedStats.has('rebalances') }">
|
||||
<div class="stat-label">Rebalances</div>
|
||||
<div class="stat-value">{{ rebalanceCount }}</div>
|
||||
<div class="growth-badge growth-flat">{{ lastRebalance }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="freshness">{{ error ? 'Connection lost' : `Updated ${secondsSinceUpdate}s ago` }}</div>
|
||||
</div>
|
||||
<div v-else-if="!error && !stats" class="live-stats">
|
||||
<div class="stats-grid">
|
||||
|
|
@ -86,6 +91,10 @@ let refreshInterval: number | null = null;
|
|||
let ethPriceInterval: number | null = null;
|
||||
let ethPriceCacheTime = 0;
|
||||
const ETH_PRICE_CACHE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const changedStats = ref<Set<string>>(new Set());
|
||||
const secondsSinceUpdate = ref(0);
|
||||
let freshnessTicker: number | null = null;
|
||||
let changeTimeout: number | null = null;
|
||||
|
||||
// Helper: safely convert a Wei BigInt string to ETH float
|
||||
function weiToEth(wei: string | null | undefined): number {
|
||||
|
|
@ -466,7 +475,29 @@ async function fetchStats() {
|
|||
}
|
||||
} catch { /* ignore RPC fallback errors */ }
|
||||
}
|
||||
// Detect changed fields for flash animation
|
||||
const prev = stats.value;
|
||||
if (prev) {
|
||||
const changed = new Set<string>();
|
||||
if (s.lastEthReserve !== prev.lastEthReserve) {
|
||||
changed.add('ethReserve');
|
||||
changed.add('ethPerToken');
|
||||
}
|
||||
if (s.kraikenTotalSupply !== prev.kraikenTotalSupply) {
|
||||
changed.add('supply');
|
||||
changed.add('ethPerToken');
|
||||
}
|
||||
if (s.floorPriceWei !== prev.floorPriceWei) changed.add('floorPrice');
|
||||
if (s.holderCount !== prev.holderCount) changed.add('holders');
|
||||
if (s.recentersLastWeek !== prev.recentersLastWeek) changed.add('rebalances');
|
||||
if (changed.size > 0) {
|
||||
changedStats.value = changed;
|
||||
if (changeTimeout) clearTimeout(changeTimeout);
|
||||
changeTimeout = window.setTimeout(() => { changedStats.value = new Set(); }, 700);
|
||||
}
|
||||
}
|
||||
stats.value = s;
|
||||
secondsSinceUpdate.value = 0;
|
||||
error.value = false;
|
||||
} else {
|
||||
throw new Error('No stats data');
|
||||
|
|
@ -478,16 +509,19 @@ async function fetchStats() {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats();
|
||||
onMounted(async () => {
|
||||
await fetchStats();
|
||||
fetchEthPrice();
|
||||
refreshInterval = window.setInterval(fetchStats, 30000); // Refresh every 30 seconds
|
||||
ethPriceInterval = window.setInterval(fetchEthPrice, ETH_PRICE_CACHE_MS); // Refresh ETH price every 5 min
|
||||
ethPriceInterval = window.setInterval(fetchEthPrice, ETH_PRICE_CACHE_MS);
|
||||
freshnessTicker = window.setInterval(() => { secondsSinceUpdate.value++; }, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
if (ethPriceInterval) clearInterval(ethPriceInterval);
|
||||
if (freshnessTicker) clearInterval(freshnessTicker);
|
||||
if (changeTimeout) clearTimeout(changeTimeout);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -590,6 +624,57 @@ onUnmounted(() => {
|
|||
background: rgba(117, 80, 174, 0.2)
|
||||
border-color: rgba(117, 80, 174, 0.5)
|
||||
|
||||
.live-header
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: 6px
|
||||
margin-bottom: 16px
|
||||
|
||||
.live-dot
|
||||
width: 8px
|
||||
height: 8px
|
||||
border-radius: 50%
|
||||
background: #4ade80
|
||||
animation: dot-pulse 2s ease-in-out infinite
|
||||
|
||||
.live-dot-error
|
||||
background: #ef4444
|
||||
animation: none
|
||||
|
||||
.live-text
|
||||
font-size: 11px
|
||||
color: rgba(240, 240, 240, 0.5)
|
||||
text-transform: uppercase
|
||||
letter-spacing: 1px
|
||||
font-weight: 500
|
||||
|
||||
@keyframes dot-pulse
|
||||
0%, 100%
|
||||
opacity: 1
|
||||
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4)
|
||||
50%
|
||||
opacity: 0.7
|
||||
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0)
|
||||
|
||||
.freshness
|
||||
text-align: center
|
||||
font-size: 11px
|
||||
color: rgba(240, 240, 240, 0.35)
|
||||
margin-top: 16px
|
||||
letter-spacing: 0.3px
|
||||
|
||||
.stat-changed
|
||||
animation: stat-flash 0.7s ease-out
|
||||
|
||||
@keyframes stat-flash
|
||||
0%
|
||||
background: rgba(74, 222, 128, 0.15)
|
||||
border-color: rgba(74, 222, 128, 0.3)
|
||||
100%
|
||||
background: rgba(255, 255, 255, 0.03)
|
||||
border-color: rgba(255, 255, 255, 0.08)
|
||||
|
||||
.skeleton
|
||||
pointer-events: none
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue