Skip to content

Commit eba29a2

Browse files
authored
Merge pull request #130 from hack-a-chain-software/feat/usd-asset-price
feat: usd asset price
2 parents cc29eac + ab9f32d commit eba29a2

File tree

4 files changed

+249
-38
lines changed

4 files changed

+249
-38
lines changed

components/account/AssetsPieChart.vue

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
<script setup lang="ts">
22
import { Doughnut } from 'vue-chartjs'
33
import { useAccountBalances } from '~/composables/useAccountBalances'
4+
import { useAssetUsdPrices } from '~/composables/useAssetUsdPrices'
45
import { staticTokens } from '~/constants/tokens'
56
67
const { balances } = useAccountBalances()
8+
const { getUsdPerUnit, primeModules } = useAssetUsdPrices()
79
810
// Fixed chart size (same on desktop and mobile) to keep the donut perfectly round
911
// Slightly reduced to give more space to the legend
@@ -33,40 +35,44 @@ const nameForModule = (module: string) => {
3335
const PALETTE = ['#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981', '#06b6d4', '#3b82f6', '#8b5cf6']
3436
const OTHERS_COLOR = '#4b5563'
3537
36-
// Group balances by module (specific chain when viewing All chains), sort by amount desc
38+
// Group balances by USD value; when All Chains selected, aggregate by module across chains.
3739
const groupedByAsset = computed(() => {
38-
const map = new Map<string, { module: string; label: string; amount: number }>()
40+
const map = new Map<string, { module: string; label: string; usd: number }>()
3941
for (const b of (balances.value || [])) {
4042
const amount = Number(b?.balance || 0)
4143
if (!amount || amount <= 0) continue
4244
const baseModule = b.module || 'unknown'
43-
const key = isAllChains.value ? `${baseModule}|${b.chainId}` : baseModule
44-
const label = isAllChains.value ? `${nameForModule(baseModule)} (c${b.chainId})` : nameForModule(baseModule)
45+
const unitUsd = getUsdPerUnit(baseModule)
46+
const usd = Number.isFinite(unitUsd) ? unitUsd * amount : 0
47+
const key = isAllChains.value ? baseModule : `${baseModule}|${b.chainId}`
48+
const label = isAllChains.value ? nameForModule(baseModule) : `${nameForModule(baseModule)} (c${b.chainId})`
4549
const prev = map.get(key)
4650
if (prev) {
47-
prev.amount += amount
51+
prev.usd += usd
4852
} else {
49-
map.set(key, { module: key, label, amount })
53+
map.set(key, { module: key, label, usd })
54+
}
55+
if (process.client) {
56+
console.debug('[pie] module', baseModule, 'amount', amount, 'unitUSD', unitUsd, 'usd+', usd, 'key', key)
5057
}
5158
}
52-
return Array.from(map.values()).sort((a, b) => b.amount - a.amount)
59+
return Array.from(map.values()).sort((a, b) => b.usd - a.usd)
5360
})
5461
55-
// Total based on amounts for now
56-
// NOTE: In the future, switch to USD value once pricing is available
57-
const totalBalance = computed(() => groupedByAsset.value.reduce((acc, i) => acc + Number(i.amount || 0), 0))
62+
// Total USD
63+
const totalBalance = computed(() => groupedByAsset.value.reduce((acc, i) => acc + Number(i.usd || 0), 0))
5864
5965
// Top 8 slices + aggregate others
6066
const slices = computed(() => {
6167
const SLICE_CAP = 8
6268
const top = groupedByAsset.value.slice(0, SLICE_CAP)
6369
if (groupedByAsset.value.length <= SLICE_CAP) return top
64-
const othersAmount = groupedByAsset.value.slice(SLICE_CAP).reduce((a, i) => a + i.amount, 0)
65-
return [...top, { module: 'others', label: 'OTHERS', amount: othersAmount }]
70+
const othersAmount = groupedByAsset.value.slice(SLICE_CAP).reduce((a, i) => a + i.usd, 0)
71+
return [...top, { module: 'others', label: 'OTHERS', usd: othersAmount }]
6672
})
6773
6874
const labels = computed(() => slices.value.map(s => s.label))
69-
const dataValues = computed(() => slices.value.map(s => s.amount))
75+
const dataValues = computed(() => slices.value.map(s => s.usd))
7076
const backgroundColors = computed(() => slices.value.map((s, i) => (s.module === 'others' ? OTHERS_COLOR : PALETTE[i % PALETTE.length])))
7177
7278
const chartData = computed(() => ({
@@ -128,6 +134,7 @@ const externalTooltipHandler = (context: any) => {
128134
tooltipEl.style.padding = '8px 10px'
129135
tooltipEl.style.color = '#fafafa'
130136
tooltipEl.style.fontSize = '12px'
137+
tooltipEl.style.whiteSpace = 'normal'
131138
tooltipEl.style.zIndex = '50'
132139
chart.canvas.parentNode.appendChild(tooltipEl)
133140
}
@@ -141,14 +148,8 @@ const externalTooltipHandler = (context: any) => {
141148
const index = tooltip.dataPoints[0].dataIndex
142149
const label = labels.value[index]
143150
const value = dataValues.value[index]
144-
// Percentage based on amount for now. Change to USD later when prices are available.
145151
const pct = totalBalance.value > 0 ? ((value / totalBalance.value) * 100).toFixed(2) : '0.00'
146-
tooltipEl.innerHTML = `<div class="flex items-center gap-2">
147-
<div class="w-2.5 h-2.5 rounded-full" style="background:${backgroundColors.value[index]}"></div>
148-
<div class="font-medium">${label}</div>
149-
</div>
150-
<div class="text-[#bbbbbb] mt-1">Quantity: ${value}</div>
151-
<div class="text-[#bbbbbb]">Share: ${pct}%</div>`
152+
tooltipEl.innerHTML = `<div class=\"flex items-center gap-2\" style=\"white-space:nowrap\">\n <div class=\"w-2.5 h-2.5 rounded-full\" style=\"background:${backgroundColors.value[index]}\"></div>\n <div class=\"font-medium uppercase\">${label}</div>\n </div>\n <div class=\"text-[#bbbbbb] mt-1\" style=\"white-space:nowrap\">USD: $${Number(value).toFixed(2)}</div>\n <div class=\"text-[#bbbbbb]\" style=\"white-space:nowrap\">Share: ${pct}%</div>`
152153
}
153154
154155
const { offsetLeft: positionX, offsetTop: positionY } = chart.canvas
@@ -175,11 +176,11 @@ const chartOptions = reactive({
175176

176177
<template>
177178
<div class="w-full">
178-
<template v-if="totalBalance > 0">
179+
<template v-if="groupedByAsset.length > 0">
179180
<div class="grid grid-cols-1 md:flex md:items-center">
180181
<!-- Left: Chart (60%) -->
181182
<div class="md:basis-6/12 md:shrink-0 flex justify-center md:justify-start">
182-
<div class="relative overflow-hidden" :style="{ width: chartSizePx, height: chartSizePx }">
183+
<div class="relative overflow-visible" :style="{ width: chartSizePx, height: chartSizePx }">
183184
<Doughnut
184185
:data="chartData"
185186
:options="chartOptions"
@@ -190,7 +191,7 @@ const chartOptions = reactive({
190191
<div class="absolute inset-0 flex items-center justify-center pointer-events-none select-none">
191192
<div class="text-center">
192193
<div class="text-[12px] text-[#bbbbbb]">Total</div>
193-
<div class="text-[16px] text-white font-semibold">{{ totalBalance.toLocaleString() }}</div>
194+
<div class="text-[16px] text-white font-semibold">${{ Number(totalBalance).toFixed(2) }}</div>
194195
</div>
195196
</div>
196197
</div>
@@ -203,11 +204,11 @@ const chartOptions = reactive({
203204
<div class="flex items-center gap-3 min-w-0">
204205
<div class="w-3 h-3 rounded-full" :style="{ background: (s.module === 'others' ? '#4b5563' : backgroundColors[idx]) }"></div>
205206
<div class="truncate text-[#fafafa] text-[12px]" :ref="el => setLabelRef(el as HTMLElement | null, idx)">
206-
<span class="text-[#bbbbbb] mr-1">{{ ((s.amount / (totalBalance || 1)) * 100).toFixed(2) }}%</span>
207+
<span class="text-[#bbbbbb] mr-1">{{ ((s.usd / (totalBalance || 1)) * 100).toFixed(2) }}%</span>
207208
<span class="uppercase">{{ s.label }}</span>
208209
</div>
209210
</div>
210-
<div class="text-[#fafafa] text-[12px] font-medium" :class="{ 'basis-full mt-1 text-right': shouldWrapAmount[idx] }">{{ new Intl.NumberFormat('en-US', { maximumFractionDigits: 12 }).format(s.amount) }}</div>
211+
<div class="text-[#fafafa] text-[12px] font-medium" :class="{ 'basis-full mt-1 text-right': shouldWrapAmount[idx] }">${{ Number(s.usd).toFixed(2) }}</div>
211212
</div>
212213
</div>
213214
</div>

components/account/AssetsTokens.vue

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useSharedData } from '~/composables/useSharedData'
99
import Tooltip from '~/components/Tooltip.vue'
1010
import { useFormat } from '~/composables/useFormat'
1111
import { exportableToCsv, downloadCSV } from '~/composables/csv'
12+
import { useAssetUsdPrices } from '~/composables/useAssetUsdPrices'
1213
1314
const props = defineProps<{
1415
address: string
@@ -18,6 +19,13 @@ const route = useRoute()
1819
const { selectedNetwork } = useSharedData()
1920
const { balances, loading, pageInfo } = useAccountBalances()
2021
const { truncateAddress } = useFormat()
22+
const { getUsdPerUnit, primeModules } = useAssetUsdPrices()
23+
// Prime pricing when balances change
24+
watch(balances, (arr) => {
25+
const mods = (arr || []).map((b: any) => b?.module).filter(Boolean)
26+
primeModules(mods)
27+
}, { immediate: true })
28+
2129
2230
// Table setup
2331
const headers = [
@@ -51,10 +59,15 @@ const pageSlice = computed(() => {
5159
return filteredRows.value.slice(start, start + rowsToShow.value)
5260
})
5361
54-
function formatUsd(num: number) {
62+
function formatUsdFixed2(num: number) {
5563
return `$${new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num)}`
5664
}
5765
66+
function formatUsdUpTo8(num: number) {
67+
if (num > 0 && num < 1e-8) return '<$0.00000001'
68+
return `$${new Intl.NumberFormat('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 8 }).format(num)}`
69+
}
70+
5871
const iconForModule = (module: string) => {
5972
const token = staticTokens.find(t => t.module === module) || (module === 'coin' ? staticTokens.find(t => t.module === 'coin') : null)
6073
return token?.icon || ''
@@ -69,7 +82,7 @@ const nameForModule = (module: string) => {
6982
return fallback
7083
}
7184
72-
const priceForModule = (_module: string) => 0 // USD price hardcoded for now
85+
const unitUsdForModule = (module: string) => getUsdPerUnit(module) || 0
7386
7487
const chainFromQuery = computed(() => {
7588
const q = route.query.chain as string | undefined
@@ -84,33 +97,37 @@ const flattenedRows = computed(() => {
8497
.filter((b: any) => Number(b.balance) > 0)
8598
.map((b: any) => {
8699
const amountNum = Number(b.balance)
87-
const price = priceForModule(b.module)
88-
const value = price * amountNum
100+
const unitUsd = unitUsdForModule(b.module)
101+
const value = unitUsd * amountNum
102+
if (process.client) {
103+
console.debug('[table] module', b.module, 'amount', amountNum, 'unitUSD', unitUsd, 'value', value)
104+
}
89105
return {
90106
asset: nameForModule(b.module),
91107
module: b.module,
92108
chain: b.chainId,
93-
price: formatUsd(price),
109+
price: formatUsdUpTo8(unitUsd),
94110
amount: new Intl.NumberFormat('en-US', { maximumFractionDigits: 12 }).format(amountNum),
95-
value: formatUsd(value),
111+
value: formatUsdFixed2(parseFloat(value.toFixed(2))),
96112
_sortValue: value,
97113
_icon: iconForModule(b.module),
114+
_amountRaw: amountNum,
98115
}
99116
})
100117
})
101118
102119
// Helpers for displaying long module names
103120
const isLongModule = (module: string | undefined | null) => {
104121
const ns = (module || '').split('.')?.[0] || ''
105-
return ns.length > 20
122+
return ns.length > 18
106123
}
107124
108125
const displayModule = (module: string | undefined | null) => {
109126
const text = module || ''
110127
const [ns, name] = text.split('.')
111-
if (ns && ns.length > 20) {
112-
const truncated = truncateAddress(ns, 8, 6)
113-
return name ? `${truncated}.${name}` : truncated
128+
if (ns && ns.length > 18) {
129+
// For long namespaces, display only the module name (second part)
130+
return name || text
114131
}
115132
return text
116133
}

components/account/TokenHoldings.vue

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { ref, computed, watch } from 'vue'
33
import { staticTokens, unknownToken, unknownNft } from '~/constants/tokens'
44
import { useAccountNFTs } from '~/composables/useAccountNFTs'
5+
import { useAssetUsdPrices } from '~/composables/useAssetUsdPrices'
56
67
const props = defineProps<{
78
loading: boolean,
@@ -14,6 +15,9 @@ const emit = defineEmits<{
1415
1516
const open = ref(false)
1617
const search = ref('')
18+
const { getUsdPerUnit, primeModules } = useAssetUsdPrices()
19+
const formatUsd = (v: number) => `$${new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(v || 0)}`
20+
const formatAmount12 = (v: string | number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 12 }).format(Number(v || 0))
1721
1822
function deriveNameFromModule(module: string): string {
1923
const text = module || ''
@@ -28,16 +32,24 @@ const tokenItems = computed(() => {
2832
const meta = staticTokens.find(t => t.module === n.module) || unknownToken
2933
const fallbackToModule = !meta?.name || meta.name.toLowerCase() === 'unknown'
3034
const name = fallbackToModule ? deriveNameFromModule(n.module) : meta.name
35+
const unitUsd = getUsdPerUnit(n.module)
36+
const amountNum = Number(n.balance || 0)
37+
const usd = Number.isFinite(unitUsd) ? parseFloat((unitUsd * amountNum).toFixed(2)) : 0
38+
if (process.client) {
39+
console.debug('[holdings] item', n.module, 'amount', amountNum, 'unitUSD', unitUsd, 'usd', usd)
40+
}
3141
return {
3242
type: 'token',
3343
name,
3444
module: n.module,
3545
icon: meta.icon || '',
3646
chainId: n.chainId,
3747
amount: n.balance,
48+
usd,
3849
}
3950
})
40-
return list
51+
// sort by USD desc
52+
return list.sort((a: any, b: any) => (b.usd || 0) - (a.usd || 0))
4153
})
4254
4355
// NFTs sourced from composable already used elsewhere on the page
@@ -108,6 +120,12 @@ const onViewAll = () => {
108120
emit('view-all-assets')
109121
close()
110122
}
123+
124+
// Prime prices when dropdown receives balances
125+
watch(() => props.balances, (arr) => {
126+
const mods = (arr || []).map((b: any) => b?.module).filter(Boolean)
127+
primeModules(mods)
128+
}, { immediate: true })
111129
</script>
112130

113131
<template>
@@ -171,8 +189,8 @@ const onViewAll = () => {
171189
</div>
172190
</div>
173191
<div class="text-right">
174-
<div class="text-[14px]">$0.0</div>
175-
<div v-if="Number(item.amount) > 0" class="text-[12px] text-[#bbbbbb]">{{ item.amount }}</div>
192+
<div class="text-[14px]">{{ formatUsd(item.usd || 0) }}</div>
193+
<div v-if="Number(item.amount) > 0" class="text-[12px] text-[#bbbbbb]">{{ formatAmount12(item.amount) }}</div>
176194
</div>
177195
</div>
178196
</div>

0 commit comments

Comments
 (0)