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:
johba 2026-02-23 00:36:04 +01:00
commit 8a5a4f7e6d

View file

@ -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