import { Panel } from './Panel'; import { getRpcBaseUrl } from '@/services/rpc-client'; import { t } from '@/services/i18n'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; import type { ListEtfFlowsResponse } from '@/generated/client/worldmonitor/market/v1/service_client'; import { getHydratedData } from '@/services/bootstrap'; import { MarketServiceClient } from '@/services/generated-rpc-clients'; type ETFFlowsResult = ListEtfFlowsResponse; function formatVolume(v: number): string { if (Math.abs(v) >= 1e9) return `${(v / 1e9).toFixed(1)}B`; if (Math.abs(v) >= 1e6) return `${(v / 1e6).toFixed(1)}M`; if (Math.abs(v) >= 1e3) return `${(v / 1e3).toFixed(0)}K`; return v.toLocaleString(); } function flowClass(direction: string): string { if (direction === 'inflow') return 'flow-inflow'; if (direction === 'outflow') return 'flow-outflow'; return 'flow-neutral'; } function changeClass(val: number): string { if (val > 0.1) return 'change-positive'; if (val < -0.1) return 'change-negative'; return 'change-neutral'; } export class ETFFlowsPanel extends Panel { private data: ETFFlowsResult | null = null; private loading = true; private error: string | null = null; constructor() { super({ id: 'etf-flows', title: t('panels.etfFlows'), showCount: false, infoTooltip: t('components.etfFlows.infoTooltip') }); } public async fetchData(): Promise { const hydrated = getHydratedData('etfFlows') as ETFFlowsResult | undefined; if (hydrated?.etfs?.length) { this.data = hydrated; this.error = null; this.loading = false; this.renderPanel(); void this.refreshFromRpc(); return; } await this.refreshFromRpc(); } private async refreshFromRpc(): Promise { try { const client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) }); const fresh = await client.listEtfFlows({}); if (!this.element?.isConnected) return; if (fresh.etfs?.length || !this.data) { this.data = fresh; this.error = null; this.loading = false; this.renderPanel(); } } catch (err) { if (this.isAbortError(err)) return; if (!this.element?.isConnected) return; if (!this.data) { console.warn('[ETFFlows] Fetch error:', err); this.error = t('components.etfFlows.unavailable'); this.loading = false; this.renderPanel(); } } } private renderPanel(): void { if (this.loading) { this.showLoading(t('common.loadingEtfData')); return; } if (this.error || !this.data) { this.showError(this.error || t('common.noDataShort'), () => void this.fetchData()); return; } const d = this.data; if (!d.etfs?.length) { const msg = d.rateLimited ? t('components.etfFlows.rateLimited') : t('components.etfFlows.unavailable'); this.setSafeContent(unsafeRawHtml(`
${msg}
`, 'legacy Panel.setContent() migration')); return; } const s = d.summary || { etfCount: 0, totalVolume: 0, totalEstFlow: 0, netDirection: 'NEUTRAL', inflowCount: 0, outflowCount: 0 }; const dirClass = s.netDirection.includes('INFLOW') ? 'flow-inflow' : s.netDirection.includes('OUTFLOW') ? 'flow-outflow' : 'flow-neutral'; const rows = d.etfs.map(etf => ` ${escapeHtml(etf.ticker)} ${escapeHtml(etf.issuer)} ${etf.direction === 'inflow' ? '+' : etf.direction === 'outflow' ? '-' : ''}$${formatVolume(Math.abs(etf.estFlow))} ${formatVolume(etf.volume)} ${etf.priceChange > 0 ? '+' : ''}${etf.priceChange.toFixed(2)}% `).join(''); const html = `
${t('components.etfFlows.netFlow')} ${s.netDirection.includes('INFLOW') ? t('components.etfFlows.netInflow') : t('components.etfFlows.netOutflow')}
${t('components.etfFlows.estFlow')} $${formatVolume(Math.abs(s.totalEstFlow))}
${t('components.etfFlows.totalVol')} ${formatVolume(s.totalVolume)}
${t('components.etfFlows.etfs')} ${s.inflowCount}↑ ${s.outflowCount}↓
${rows}
${t('components.etfFlows.table.ticker')} ${t('components.etfFlows.table.issuer')} ${t('components.etfFlows.table.estFlow')} ${t('components.etfFlows.table.volume')} ${t('components.etfFlows.table.change')}
`; this.setSafeContent(unsafeRawHtml(html, 'legacy Panel.setContent() migration')); } }