diff --git a/apps/webapp/package.json b/apps/webapp/package.json index df96699..38c8844 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -28,6 +28,7 @@ "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "recharts": "^2.15.0", "styletron-engine-monolithic": "^1.0.0", "styletron-react": "^6.1.1", "viem": "^2.21.45", diff --git a/apps/webapp/src/components/Header.tsx b/apps/webapp/src/components/Header.tsx index 0e629ee..6fb2b36 100644 --- a/apps/webapp/src/components/Header.tsx +++ b/apps/webapp/src/components/Header.tsx @@ -120,6 +120,7 @@ const navItems = [ { href: "/dashboard", label: "dashboard" }, { href: "/boost", label: "veMEZO" }, { href: "/incentives", label: "veBTC" }, + { href: "/analytics", label: "analytics" }, { href: "/how-to", label: "how-to" }, ] diff --git a/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx b/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx new file mode 100644 index 0000000..2192c53 --- /dev/null +++ b/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx @@ -0,0 +1,250 @@ +import { SpringIn } from "@/components/SpringIn" +import { useVoterTotals } from "@/hooks/useGauges" +import { useProtocolHistory } from "@/hooks/useProtocolHistory" +import { Skeleton } from "@mezo-org/mezo-clay" +import { useMemo } from "react" +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts" +import { formatUnits } from "viem" + +function formatCompactNumber(value: bigint | undefined): string { + if (value === undefined) return "—" + const num = Number(formatUnits(value, 18)) + if (!Number.isFinite(num)) return "—" + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B` + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M` + if (num >= 1_000) return `${(num / 1_000).toFixed(2)}K` + return num.toLocaleString(undefined, { maximumFractionDigits: 2 }) +} + +function formatCompactNumberFromString(value: string): number { + // totalVemezoWeight comes back as a raw string (1e18 precision). + // Convert to a float for charting purposes. + try { + return Number(formatUnits(BigInt(value), 18)) + } catch { + return 0 + } +} + +function formatChartValue(value: number): string { + if (!Number.isFinite(value)) return "0" + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B` + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M` + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K` + return value.toFixed(0) +} + +function formatDateLabel(epochStart: number): string { + const date = new Date(epochStart * 1000) + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} + +type TooltipDatum = { + dateLabel: string + vemezoWeight: number + epochStart: number +} + +type TooltipProps = { + active?: boolean + payload?: Array<{ payload: TooltipDatum }> +} + +function CustomTooltip({ active, payload }: TooltipProps) { + if (!active || !payload || payload.length === 0) return null + const first = payload[0] + if (!first) return null + const datum = first.payload + const date = new Date(datum.epochStart * 1000) + + return ( +
+
+ {date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + })} +
+
+ {formatChartValue(datum.vemezoWeight)} +
+
+ veMEZO weight +
+
+ ) +} + +export function AnalyticsEarningPower(): JSX.Element { + const { + veBTCTotalVotingPower, + veMEZOTotalVotingPower, + isLoading: isLoadingVoter, + } = useVoterTotals() + const { epochs, isLoading: isLoadingHistory } = useProtocolHistory("3m") + + const chartData = useMemo(() => { + return epochs.map((e) => ({ + dateLabel: formatDateLabel(e.epochStart), + vemezoWeight: formatCompactNumberFromString(e.totalVemezoWeight), + epochStart: e.epochStart, + })) + }, [epochs]) + + const hasChartData = chartData.some((d) => d.vemezoWeight > 0) + + return ( + +
+
+
+

+ $ earning power +

+

+ Total veBTC and veMEZO voting power over time +

+
+
+ +
+
+
+ veBTC voting power +
+
+ {isLoadingVoter && veBTCTotalVotingPower === undefined ? ( + + ) : ( +
+ {formatCompactNumber(veBTCTotalVotingPower)} +
+ )} +
+
+ locked BTC earning power +
+
+
+
+ veMEZO voting power +
+
+ {isLoadingVoter && veMEZOTotalVotingPower === undefined ? ( + + ) : ( +
+ {formatCompactNumber(veMEZOTotalVotingPower)} +
+ )} +
+
+ locked MEZO boosting power +
+
+
+ +
+
+ veMEZO weight history (90d) +
+
+ {isLoadingHistory ? ( +
+ +
+ ) : !hasChartData ? ( +
+
+ $ no historical data +
+
+ Weight history will appear once epochs accumulate +
+
+ ) : ( + + + + + + + + + + + formatChartValue(Number(v))} + tickLine={false} + axisLine={{ stroke: "var(--border)" }} + width={56} + /> + } + cursor={{ + stroke: "var(--border)", + strokeDasharray: "3 3", + }} + /> + + + + )} +
+
+
+
+ ) +} + +export default AnalyticsEarningPower diff --git a/apps/webapp/src/components/analytics/AnalyticsGaugeVotes.tsx b/apps/webapp/src/components/analytics/AnalyticsGaugeVotes.tsx new file mode 100644 index 0000000..b0a6db8 --- /dev/null +++ b/apps/webapp/src/components/analytics/AnalyticsGaugeVotes.tsx @@ -0,0 +1,478 @@ +import { SpringIn } from "@/components/SpringIn" +import type { GaugeProfile } from "@/config/supabase" +import { useAllGaugeProfiles } from "@/hooks/useGaugeProfiles" +import { + type EpochVotesBundle, + type GaugeVoteRow, + useGaugeVotesByEpoch, +} from "@/hooks/useGaugeVotesByEpoch" +import { ChevronLeft, ChevronRight, Skeleton, Tag } from "@mezo-org/mezo-clay" +import { useMemo, useState } from "react" +import { formatUnits } from "viem" + +type WeightBasis = "vebtc" | "vemezo" + +const TOP_N = 12 + +const WEIGHT_BASIS_LABELS: Record = { + vebtc: "veBTC", + vemezo: "veMEZO", +} + +const WEIGHT_BASIS_DESCRIPTIONS: Record = { + vebtc: "BTC-backed vote allocation", + vemezo: "MEZO-backed boost allocation", +} + +function truncateAddress(addr: string): string { + return `${addr.slice(0, 6)}…${addr.slice(-4)}` +} + +function formatEpochLabel(epochStart: number): string { + const date = new Date(epochStart * 1000) + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +function formatShortEpochLabel(epochStart: number): string { + const date = new Date(epochStart * 1000) + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) +} + +function safeBigInt(value: string | undefined | null): bigint { + if (!value) return 0n + try { + return BigInt(value) + } catch { + return 0n + } +} + +function formatWeight(value: string | undefined | null): string { + const big = safeBigInt(value) + if (big === 0n) return "0" + const num = Number(formatUnits(big, 18)) + if (!Number.isFinite(num)) return "0" + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B` + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M` + if (num >= 1_000) return `${(num / 1_000).toFixed(2)}K` + if (num >= 1) return num.toFixed(2) + return num.toFixed(4) +} + +function formatPercent(fraction: number): string { + if (!Number.isFinite(fraction) || fraction <= 0) return "0.00%" + const pct = fraction * 100 + if (pct < 0.01) return "<0.01%" + return `${pct.toFixed(2)}%` +} + +function formatDelta(delta: number): { + label: string + sign: "up" | "down" | "zero" +} { + if (!Number.isFinite(delta) || Math.abs(delta) < 0.0001) { + return { label: "—", sign: "zero" } + } + const pct = delta * 100 + const sign = delta > 0 ? "up" : "down" + const prefix = delta > 0 ? "+" : "" + return { + label: `${prefix}${pct.toFixed(2)}pp`, + sign, + } +} + +type VoteRow = { + gaugeAddress: string + displayName: string + currentWeight: bigint + currentShare: number + previousShare: number | null + delta: number | null + incentivesUsd: number + isNew: boolean + isDropped: boolean +} + +function computeRows( + current: EpochVotesBundle, + previous: EpochVotesBundle | undefined, + basis: WeightBasis, + profileMap: Map, +): VoteRow[] { + const getWeight = (row: GaugeVoteRow) => + safeBigInt(basis === "vebtc" ? row.vebtcWeight : row.vemezoWeight) + + const totalBig = safeBigInt( + basis === "vebtc" ? current.totalVebtcWeight : current.totalVemezoWeight, + ) + + const prevTotalBig = previous + ? safeBigInt( + basis === "vebtc" + ? previous.totalVebtcWeight + : previous.totalVemezoWeight, + ) + : 0n + + const prevShareMap = new Map() + if (previous && prevTotalBig > 0n) { + const prevTotalNum = Number(formatUnits(prevTotalBig, 18)) + for (const row of previous.gauges) { + const w = getWeight(row) + if (w === 0n) continue + const shareNum = Number(formatUnits(w, 18)) / prevTotalNum + prevShareMap.set(row.gaugeAddress.toLowerCase(), shareNum) + } + } + + const totalNum = totalBig > 0n ? Number(formatUnits(totalBig, 18)) : 0 + + const rows: VoteRow[] = current.gauges.map((row) => { + const weight = getWeight(row) + const weightNum = Number(formatUnits(weight, 18)) + const share = totalNum > 0 ? weightNum / totalNum : 0 + const prevShare = prevShareMap.get(row.gaugeAddress.toLowerCase()) ?? null + const delta = prevShare !== null ? share - prevShare : null + const profile = profileMap.get(row.gaugeAddress.toLowerCase()) + const displayName = + profile?.display_name ?? truncateAddress(row.gaugeAddress) + + return { + gaugeAddress: row.gaugeAddress, + displayName, + currentWeight: weight, + currentShare: share, + previousShare: prevShare, + delta, + incentivesUsd: row.totalIncentivesUsd, + isNew: prevShare === null && share > 0, + isDropped: false, + } + }) + + // Surface gauges that existed previously but dropped out entirely. + const currentKeys = new Set(rows.map((r) => r.gaugeAddress.toLowerCase())) + if (previous) { + for (const [addr, prevShare] of prevShareMap.entries()) { + if (currentKeys.has(addr)) continue + if (prevShare <= 0) continue + const profile = profileMap.get(addr) + rows.push({ + gaugeAddress: addr, + displayName: profile?.display_name ?? truncateAddress(addr), + currentWeight: 0n, + currentShare: 0, + previousShare: prevShare, + delta: -prevShare, + incentivesUsd: 0, + isNew: false, + isDropped: true, + }) + } + } + + rows.sort((a, b) => { + if (a.currentShare !== b.currentShare) { + return b.currentShare - a.currentShare + } + return (b.previousShare ?? 0) - (a.previousShare ?? 0) + }) + + return rows +} + +type BarRowProps = { + row: VoteRow + maxShare: number + basis: WeightBasis +} + +function BarRow({ row, maxShare, basis }: BarRowProps) { + const widthPct = + maxShare > 0 ? Math.max(2, (row.currentShare / maxShare) * 100) : 0 + const delta = row.delta !== null ? formatDelta(row.delta) : null + + const barColor = basis === "vebtc" ? "#F7931A" : "var(--positive)" + + return ( +
+
+
+ + {row.displayName} + + {row.isNew && ( + + new + + )} + {row.isDropped && ( + + dropped + + )} +
+
+
+
+
+
+ {formatPercent(row.currentShare)} +
+
+ {delta ? ( + + {delta.label} + + ) : ( + + )} +
+
+ ) +} + +type BasisPillProps = { + basis: WeightBasis + activeBasis: WeightBasis + onClick: (b: WeightBasis) => void +} + +function BasisPill({ basis, activeBasis, onClick }: BasisPillProps) { + const isActive = basis === activeBasis + return ( + + ) +} + +export function AnalyticsGaugeVotes(): JSX.Element { + const [basis, setBasis] = useState("vebtc") + const [epochIndex, setEpochIndex] = useState(0) + const [showAll, setShowAll] = useState(false) + + const { epochs, isLoading } = useGaugeVotesByEpoch(12) + const { profiles } = useAllGaugeProfiles() + + const safeIndex = Math.min(epochIndex, Math.max(0, epochs.length - 1)) + const current = epochs[safeIndex] + const previous = epochs[safeIndex + 1] + + const rows = useMemo(() => { + if (!current) return [] + return computeRows(current, previous, basis, profiles) + }, [current, previous, basis, profiles]) + + const visibleRows = showAll ? rows : rows.slice(0, TOP_N) + + const maxShare = rows.reduce( + (max, r) => (r.currentShare > max ? r.currentShare : max), + 0, + ) + + const canGoOlder = safeIndex < epochs.length - 1 + const canGoNewer = safeIndex > 0 + + const epochLabel = current ? formatEpochLabel(current.epochStart) : "—" + const isLatest = safeIndex === 0 + + return ( + +
+
+
+

+ $ gauge vote share +

+

+ {WEIGHT_BASIS_DESCRIPTIONS[basis]} across all gauges +

+
+
+ + +
+
+ +
+
+ +
+ + epoch + + + {epochLabel} + {isLatest && ( + + current + + )} + +
+ +
+ + {epochs.length > 0 && ( +
+ {epochs.map((e, idx) => ( + + ))} +
+ )} +
+ + {current && ( +
+ + r.currentShare > 0).length.toString()} + /> + + +
+ )} + + {isLoading && rows.length === 0 ? ( +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+ ) : rows.length === 0 ? ( +
+

+ $ no vote data for this + epoch +

+

+ Vote weights will appear once gauge history is recorded +

+
+ ) : ( + <> +
+
Gauge
+
Share
+
Δ vs prev
+
+
+ {visibleRows.map((row) => ( + + ))} +
+ {rows.length > TOP_N && ( +
+ +
+ )} + + )} +
+
+ ) +} + +function StatBox({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +export default AnalyticsGaugeVotes diff --git a/apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx b/apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx new file mode 100644 index 0000000..3175ac3 --- /dev/null +++ b/apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx @@ -0,0 +1,287 @@ +import PaginationControls from "@/components/PaginationControls" +import { SpringIn } from "@/components/SpringIn" +import { type GaugeAPYData, formatAPY, useGaugesAPY } from "@/hooks/useAPY" +import { useAllGaugeProfiles } from "@/hooks/useGaugeProfiles" +import { type BoostGauge, useBoostGauges } from "@/hooks/useGauges" +import { usePagination } from "@/hooks/usePagination" +import { formatUsdValue } from "@/hooks/useTokenPrices" +import { formatFixedPoint, formatMultiplier } from "@/utils/format" +import { ChevronDown, ChevronUp, Input, Skeleton } from "@mezo-org/mezo-clay" +import Link from "next/link" +import type React from "react" +import { useCallback, useDeferredValue, useMemo, useState } from "react" + +type SortColumn = "veMEZOWeight" | "boost" | "apy" | "incentives" | null +type SortDirection = "asc" | "desc" + +const PAGE_SIZE = 8 + +function truncateAddress(addr: string): string { + return `${addr.slice(0, 6)}…${addr.slice(-4)}` +} + +export function AnalyticsGaugesTable(): JSX.Element { + const { gauges, isLoading } = useBoostGauges({ includeOwnership: false }) + const { profiles: gaugeProfiles } = useAllGaugeProfiles() + + const gaugesForAPY = useMemo( + () => + gauges.map((g) => ({ address: g.address, totalWeight: g.totalWeight })), + [gauges], + ) + const { apyMap } = useGaugesAPY(gaugesForAPY) + + const [sortColumn, setSortColumn] = useState("incentives") + const [sortDirection, setSortDirection] = useState("desc") + const [searchInput, setSearchInput] = useState("") + const deferredSearch = useDeferredValue(searchInput) + + const handleSort = useCallback((column: SortColumn) => { + setSortColumn((prev) => { + if (prev === column) { + setSortDirection((d) => (d === "asc" ? "desc" : "asc")) + return prev + } + setSortDirection("desc") + return column + }) + }, []) + + const SortIndicator = ({ column }: { column: SortColumn }) => { + if (sortColumn !== column) { + return ( + + + + ) + } + return sortDirection === "asc" ? ( + + ) : ( + + ) + } + + const SortableHeader = ({ + column, + children, + align = "left", + }: { + column: SortColumn + children: React.ReactNode + align?: "left" | "right" + }) => ( + + ) + + type GaugeRow = { + gauge: BoostGauge + apyData: GaugeAPYData | undefined + displayName: string + } + + const rows: GaugeRow[] = useMemo(() => { + const built: GaugeRow[] = gauges + .filter((g) => g.veBTCTokenId > 0n) + .map((gauge) => { + const profile = gaugeProfiles.get(gauge.address.toLowerCase()) + const apyData = apyMap.get(gauge.address.toLowerCase()) + const displayName = + profile?.display_name ?? truncateAddress(gauge.address) + return { gauge, apyData, displayName } + }) + + const search = deferredSearch.trim().toLowerCase() + const filtered = search + ? built.filter( + (r) => + r.displayName.toLowerCase().includes(search) || + r.gauge.address.toLowerCase().includes(search), + ) + : built + + if (!sortColumn) return filtered + + const sorted = [...filtered].sort((a, b) => { + let cmp = 0 + switch (sortColumn) { + case "veMEZOWeight": { + const aVal = a.gauge.totalWeight + const bVal = b.gauge.totalWeight + cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0 + break + } + case "boost": { + cmp = a.gauge.boostMultiplier - b.gauge.boostMultiplier + break + } + case "apy": { + const aApy = a.apyData?.apy ?? -1 + const bApy = b.apyData?.apy ?? -1 + cmp = aApy < bApy ? -1 : aApy > bApy ? 1 : 0 + break + } + case "incentives": { + const aInc = a.apyData?.totalIncentivesUSD ?? 0 + const bInc = b.apyData?.totalIncentivesUSD ?? 0 + cmp = aInc < bInc ? -1 : aInc > bInc ? 1 : 0 + break + } + } + return sortDirection === "asc" ? cmp : -cmp + }) + + return sorted + }, [gauges, gaugeProfiles, apyMap, sortColumn, sortDirection, deferredSearch]) + + const { + paginatedItems, + currentPage, + totalPages, + pageStart, + pageEnd, + goToNextPage, + goToPreviousPage, + } = usePagination(rows, { + pageSize: PAGE_SIZE, + resetDeps: [deferredSearch, sortColumn, sortDirection], + }) + + return ( + +
+
+
+

+ $ gauges +

+

+ All active gauges ranked by voting rewards +

+
+
+ setSearchInput(e.target.value)} + /> +
+
+ + {isLoading && rows.length === 0 ? ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) : rows.length === 0 ? ( +
+ $ no gauges match your + search +
+ ) : ( +
+ + + + + + + + + + + + {paginatedItems.map(({ gauge, apyData, displayName }) => ( + + + + + + + + ))} + +
+ + Gauge + + + + veMEZO + + + + Boost + + + + APY + + + + Incentives + +
+ + + {displayName} + + {!gauge.isAlive && ( + + inactive + + )} + + + {formatFixedPoint(gauge.totalWeight)} + + {formatMultiplier(gauge.boostMultiplier)} + + {apyData?.apy !== null && apyData?.apy !== undefined + ? formatAPY(apyData.apy) + : "—"} + + {apyData + ? formatUsdValue(apyData.totalIncentivesUSD) + : "—"} +
+
+ )} + + {rows.length > PAGE_SIZE && ( +
+ +
+ )} +
+
+ ) +} + +export default AnalyticsGaugesTable diff --git a/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx b/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx new file mode 100644 index 0000000..055a8cf --- /dev/null +++ b/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx @@ -0,0 +1,214 @@ +import { SpringIn } from "@/components/SpringIn" +import { useAnalyticsKPIs } from "@/hooks/useAnalyticsKPIs" +import { Skeleton } from "@mezo-org/mezo-clay" +import { formatUnits } from "viem" + +function formatCompactUsd(value: number | null): string { + if (value === null || !Number.isFinite(value)) return "—" + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(2)}B` + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M` + if (value >= 1_000) return `$${(value / 1_000).toFixed(2)}K` + return `$${value.toLocaleString(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })}` +} + +function formatCompactNumber(value: bigint | undefined): string { + if (value === undefined) return "—" + const num = Number(formatUnits(value, 18)) + if (!Number.isFinite(num)) return "—" + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(2)}B` + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M` + if (num >= 1_000) return `${(num / 1_000).toFixed(2)}K` + return num.toLocaleString(undefined, { maximumFractionDigits: 2 }) +} + +// Simple inline SVG icons matching the terminal/monospace aesthetic. +function TvlIcon() { + return ( + + ) +} + +function BtcIcon() { + return ( + + ) +} + +function FeesIcon() { + return ( + + ) +} + +function PowerIcon() { + return ( + + ) +} + +type KPICardProps = { + icon: React.ReactNode + label: string + value: string + sublabel?: string | undefined + accent?: "brand" | "positive" | "amber" | "cyan" + isLoading?: boolean | undefined +} + +const ACCENT_STYLES: Record, string> = { + brand: "text-[#F7931A]", + positive: "text-[var(--positive)]", + amber: "text-[#F59E0B]", + cyan: "text-[#06B6D4]", +} + +function KPICard({ + icon, + label, + value, + sublabel, + accent = "brand", + isLoading, +}: KPICardProps): JSX.Element { + return ( +
+
+ {icon} + + {label} + +
+
+ {isLoading ? ( + + ) : ( +
+ {value} +
+ )} +
+ {sublabel ? ( +
+ {sublabel} +
+ ) : null} +
+ ) +} + +export function AnalyticsKPIBar(): JSX.Element { + const kpis = useAnalyticsKPIs() + + const weekLabel = + kpis.epochWeek !== null ? `Week ${kpis.epochWeek}` : "Current Epoch" + + // Combine veBTC + veMEZO voting power for the fourth card. + const combinedVotingPower = + kpis.veBTCVotingPower !== undefined && kpis.veMEZOVotingPower !== undefined + ? kpis.veBTCVotingPower + kpis.veMEZOVotingPower + : undefined + + return ( +
+ + } + label="Global TVL" + value={formatCompactUsd(kpis.globalTvlUsd)} + sublabel="veBTC + veMEZO" + accent="brand" + isLoading={kpis.isLoading && kpis.globalTvlUsd === null} + /> + + + } + label="Total Locked BTC" + value={formatCompactUsd(kpis.totalLockedBtcUsd)} + sublabel={ + kpis.btcPrice + ? `@ $${kpis.btcPrice.toLocaleString(undefined, { maximumFractionDigits: 0 })}` + : undefined + } + accent="amber" + isLoading={kpis.isLoading && kpis.totalLockedBtcUsd === null} + /> + + + } + label={`${weekLabel} Fees`} + value={formatCompactUsd(kpis.epochFeesUsd)} + sublabel={`ends in ${kpis.epochCountdown}`} + accent="positive" + isLoading={kpis.isLoading && kpis.epochFeesUsd === null} + /> + + + } + label="Voting Power" + value={formatCompactNumber(combinedVotingPower)} + sublabel={`${kpis.gaugeCount} active gauges`} + accent="cyan" + isLoading={kpis.isLoading && combinedVotingPower === undefined} + /> + +
+ ) +} + +export default AnalyticsKPIBar diff --git a/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx b/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx new file mode 100644 index 0000000..7cf59a8 --- /dev/null +++ b/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx @@ -0,0 +1,276 @@ +import PaginationControls from "@/components/PaginationControls" +import { SpringIn } from "@/components/SpringIn" +import { type PoolRow, useEarnApiPools } from "@/hooks/useEarnApiPools" +import { usePagination } from "@/hooks/usePagination" +import { + ChevronDown, + ChevronUp, + Input, + Skeleton, + Tag, +} from "@mezo-org/mezo-clay" +import type React from "react" +import { useCallback, useDeferredValue, useMemo, useState } from "react" + +type SortColumn = "tvl" | "volume" | "fees" | "apr" | null +type SortDirection = "asc" | "desc" + +const PAGE_SIZE = 8 + +function formatCompactUsd(value: number): string { + if (!Number.isFinite(value) || value === 0) return "$0" + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(2)}B` + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M` + if (value >= 1_000) return `$${(value / 1_000).toFixed(2)}K` + return `$${value.toLocaleString(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })}` +} + +function formatAprBps(bps: number): string { + if (!Number.isFinite(bps) || bps === 0) return "—" + const pct = bps / 100 + return `${pct.toFixed(2)}%` +} + +export function AnalyticsPoolsTable(): JSX.Element { + const { pools, isLoading, isUnavailable } = useEarnApiPools() + + const [sortColumn, setSortColumn] = useState("tvl") + const [sortDirection, setSortDirection] = useState("desc") + const [searchInput, setSearchInput] = useState("") + const deferredSearch = useDeferredValue(searchInput) + + const handleSort = useCallback((column: SortColumn) => { + setSortColumn((prev) => { + if (prev === column) { + setSortDirection((d) => (d === "asc" ? "desc" : "asc")) + return prev + } + setSortDirection("desc") + return column + }) + }, []) + + const SortIndicator = ({ column }: { column: SortColumn }) => { + if (sortColumn !== column) { + return ( + + + + ) + } + return sortDirection === "asc" ? ( + + ) : ( + + ) + } + + const SortableHeader = ({ + column, + children, + }: { + column: SortColumn + children: React.ReactNode + }) => ( + + ) + + const rows: PoolRow[] = useMemo(() => { + const search = deferredSearch.trim().toLowerCase() + const filtered = search + ? pools.filter( + (p) => + p.name.toLowerCase().includes(search) || + p.token0Symbol.toLowerCase().includes(search) || + p.token1Symbol.toLowerCase().includes(search), + ) + : pools + + if (!sortColumn) return filtered + + const sorted = [...filtered].sort((a, b) => { + let cmp = 0 + switch (sortColumn) { + case "tvl": + cmp = a.tvlUsd - b.tvlUsd + break + case "volume": + cmp = a.volumeUsd - b.volumeUsd + break + case "fees": + cmp = a.feesUsd - b.feesUsd + break + case "apr": + cmp = a.aprBps - b.aprBps + break + } + return sortDirection === "asc" ? cmp : -cmp + }) + + return sorted + }, [pools, sortColumn, sortDirection, deferredSearch]) + + const { + paginatedItems, + currentPage, + totalPages, + pageStart, + pageEnd, + goToNextPage, + goToPreviousPage, + } = usePagination(rows, { + pageSize: PAGE_SIZE, + resetDeps: [deferredSearch, sortColumn, sortDirection], + }) + + return ( + +
+
+
+

+ $ liquidity pools +

+

+ TVL, volume, fees, and APR across Mezo DEX pools +

+
+ {!isUnavailable && ( +
+ setSearchInput(e.target.value)} + /> +
+ )} +
+ + {isLoading ? ( +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+ ) : isUnavailable ? ( +
+

+ $ pool data temporarily + unavailable +

+

+ The Mezo earn-api is experiencing issues. Pool analytics will + return once upstream is healthy. +

+
+ ) : rows.length === 0 ? ( +
+ $ no pools match your search +
+ ) : ( +
+ + + + + + + + + + + + {paginatedItems.map((pool) => ( + + + + + + + + ))} + +
+ + Pool + + + TVL + + Volume + + Fees + + APR +
+
+ + {pool.token0Symbol}/{pool.token1Symbol} + +
+ + {pool.type === "concentrated" ? "CL" : "AMM"} + + {pool.volatility === "stable" && ( + + stable + + )} + {pool.isVotable && ( + + votable + + )} +
+
+
+ {formatCompactUsd(pool.tvlUsd)} + + {formatCompactUsd(pool.volumeUsd)} + + {formatCompactUsd(pool.feesUsd)} + + {formatAprBps(pool.aprBps)} +
+
+ )} + + {!isUnavailable && rows.length > PAGE_SIZE && ( +
+ +
+ )} +
+
+ ) +} + +export default AnalyticsPoolsTable diff --git a/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx b/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx new file mode 100644 index 0000000..1ebe85b --- /dev/null +++ b/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx @@ -0,0 +1,219 @@ +import { SpringIn } from "@/components/SpringIn" +import { + type HistoryPeriod, + type ProtocolEpochDatum, + useProtocolHistory, +} from "@/hooks/useProtocolHistory" +import { Skeleton } from "@mezo-org/mezo-clay" +import { useMemo, useState } from "react" +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts" + +const PERIOD_LABELS: Record = { + "1m": "1 Month", + "3m": "3 Months", + all: "All Time", +} + +function formatDateLabel(epochStart: number): string { + const date = new Date(epochStart * 1000) + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} + +function formatCompactUsd(value: number): string { + if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(2)}B` + if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M` + if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K` + return `$${value.toFixed(2)}` +} + +type ChartDatum = { + dateLabel: string + incentives: number + raw: ProtocolEpochDatum +} + +type TooltipProps = { + active?: boolean + payload?: Array<{ payload: ChartDatum }> +} + +function CustomTooltip({ active, payload }: TooltipProps) { + if (!active || !payload || payload.length === 0) return null + const first = payload[0] + if (!first) return null + const datum = first.payload + const date = new Date(datum.raw.epochStart * 1000) + + return ( +
+
+ {date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + })} +
+
+ {formatCompactUsd(datum.incentives)} +
+
+ {datum.raw.gaugeCount} gauges +
+
+ ) +} + +type PeriodPillProps = { + period: HistoryPeriod + activePeriod: HistoryPeriod + onClick: (p: HistoryPeriod) => void +} + +function PeriodPill({ period, activePeriod, onClick }: PeriodPillProps) { + const isActive = period === activePeriod + return ( + + ) +} + +export function AnalyticsRevenueChart(): JSX.Element { + const [period, setPeriod] = useState("all") + const { epochs, isLoading } = useProtocolHistory(period) + + const { chartData, totalUsd } = useMemo(() => { + const data: ChartDatum[] = epochs.map((e) => ({ + dateLabel: formatDateLabel(e.epochStart), + incentives: e.totalIncentivesUsd, + raw: e, + })) + const total = data.reduce((sum, d) => sum + d.incentives, 0) + return { chartData: data, totalUsd: total } + }, [epochs]) + + return ( + +
+
+
+

+ $ protocol revenue +

+

+ Weekly gauge incentives distributed to voters +

+
+
+ {isLoading ? ( + + ) : ( +
+ {formatCompactUsd(totalUsd)} + + total + +
+ )} +
+ + + +
+
+
+ +
+ {isLoading ? ( +
+ +
+ ) : chartData.length === 0 ? ( +
+
+ $ no data available +
+
+ Historical gauge data will appear here once recorded +
+
+ ) : ( + + + + + formatCompactUsd(Number(v))} + tickLine={false} + axisLine={{ stroke: "var(--border)" }} + width={56} + /> + } + cursor={{ fill: "var(--surface-secondary)", opacity: 0.3 }} + /> + + + + )} +
+
+
+ ) +} + +export default AnalyticsRevenueChart diff --git a/apps/webapp/src/components/pages/AnalyticsPage.tsx b/apps/webapp/src/components/pages/AnalyticsPage.tsx new file mode 100644 index 0000000..d9af49d --- /dev/null +++ b/apps/webapp/src/components/pages/AnalyticsPage.tsx @@ -0,0 +1,28 @@ +import { AnalyticsEarningPower } from "@/components/analytics/AnalyticsEarningPower" +import { AnalyticsGaugeVotes } from "@/components/analytics/AnalyticsGaugeVotes" +import { AnalyticsGaugesTable } from "@/components/analytics/AnalyticsGaugesTable" +import { AnalyticsKPIBar } from "@/components/analytics/AnalyticsKPIBar" +import { AnalyticsPoolsTable } from "@/components/analytics/AnalyticsPoolsTable" +import { AnalyticsRevenueChart } from "@/components/analytics/AnalyticsRevenueChart" + +export default function AnalyticsPage(): JSX.Element { + return ( +
+
+

+ $ analytics +

+

+ Protocol performance, revenue, and earning power across Mezo +

+
+ + + + + + + +
+ ) +} diff --git a/apps/webapp/src/hooks/useAnalyticsKPIs.ts b/apps/webapp/src/hooks/useAnalyticsKPIs.ts new file mode 100644 index 0000000..1435792 --- /dev/null +++ b/apps/webapp/src/hooks/useAnalyticsKPIs.ts @@ -0,0 +1,125 @@ +import { useGaugesAPY } from "@/hooks/useAPY" +import { useBtcPrice } from "@/hooks/useBtcPrice" +import { useEpochCountdown } from "@/hooks/useEpochCountdown" +import { useBoostGauges, useVoterTotals } from "@/hooks/useGauges" +import { useMezoPrice } from "@/hooks/useMezoPrice" +import { useVeSupply } from "@/hooks/useVeSupply" +import { useMemo } from "react" + +export type AnalyticsKPIs = { + /** Global TVL = veBTC TVL + veMEZO TVL (both in USD). Null if prices not loaded. */ + globalTvlUsd: number | null + /** Total value locked in veBTC (supply * BTC price). */ + totalLockedBtcUsd: number | null + /** Current epoch total incentives across all gauges, in USD. */ + epochFeesUsd: number | null + /** Total veBTC voting power (raw bigint, 1e18 precision). */ + veBTCVotingPower: bigint | undefined + /** Total veMEZO voting power (raw bigint, 1e18 precision). */ + veMEZOVotingPower: bigint | undefined + /** Number of active gauges this epoch. */ + gaugeCount: number + /** Time remaining in current epoch, formatted string. */ + epochCountdown: string + /** Current epoch week number (approximate). */ + epochWeek: number | null + /** Current BTC and MEZO prices. */ + btcPrice: number | null + mezoPrice: number | null + /** True if any underlying data is still loading. */ + isLoading: boolean +} + +/** + * Epochs on Mezo start Thursday 00:00 UTC and run weekly. + * Calculate approximate week number since protocol inception. + * Using Thursday, Jan 2, 2025 as week 1 start (example anchor). + */ +const EPOCH_ANCHOR_UNIX = 1_735_862_400 // 2025-01-02 00:00 UTC (Thursday) + +function calculateEpochWeek(): number | null { + const now = Math.floor(Date.now() / 1000) + if (now < EPOCH_ANCHOR_UNIX) return null + const weeksSince = Math.floor((now - EPOCH_ANCHOR_UNIX) / (7 * 86_400)) + return weeksSince + 1 +} + +/** + * Composite hook that orchestrates all KPI data sources. Does not make any + * new network requests beyond what the underlying hooks already make — this + * is purely a data composition hook. + */ +export function useAnalyticsKPIs(): AnalyticsKPIs { + const { price: btcPrice, isLoading: isLoadingBtc } = useBtcPrice() + const { price: mezoPrice, isLoading: isLoadingMezo } = useMezoPrice() + const { totalVeBtc, totalVeMezo, isLoading: isLoadingSupply } = useVeSupply() + const { timeRemaining, isLoading: isLoadingEpoch } = useEpochCountdown() + const { gauges, isLoading: isLoadingGauges } = useBoostGauges({ + includeOwnership: false, + }) + const { + veBTCTotalVotingPower, + veMEZOTotalVotingPower, + isLoading: isLoadingVoterTotals, + } = useVoterTotals() + + const gaugesForAPY = useMemo( + () => + gauges.map((g) => ({ address: g.address, totalWeight: g.totalWeight })), + [gauges], + ) + const { apyMap, isLoading: isLoadingAPY } = useGaugesAPY(gaugesForAPY) + + const totalLockedBtcUsd = useMemo(() => { + if (btcPrice === null || totalVeBtc === undefined) return null + return totalVeBtc * btcPrice + }, [totalVeBtc, btcPrice]) + + const totalLockedMezoUsd = useMemo(() => { + if (mezoPrice === null || totalVeMezo === undefined) return null + return totalVeMezo * mezoPrice + }, [totalVeMezo, mezoPrice]) + + const globalTvlUsd = useMemo(() => { + if (totalLockedBtcUsd === null && totalLockedMezoUsd === null) return null + return (totalLockedBtcUsd ?? 0) + (totalLockedMezoUsd ?? 0) + }, [totalLockedBtcUsd, totalLockedMezoUsd]) + + const epochFeesUsd = useMemo(() => { + if (apyMap.size === 0) return null + let total = 0 + for (const data of apyMap.values()) { + total += data.totalIncentivesUSD + } + return total + }, [apyMap]) + + const gaugeCount = gauges.length + + const epochWeek = useMemo(() => calculateEpochWeek(), []) + + const isLoading = + isLoadingBtc || + isLoadingMezo || + isLoadingSupply || + isLoadingEpoch || + isLoadingGauges || + isLoadingVoterTotals || + isLoadingAPY + + return { + globalTvlUsd, + totalLockedBtcUsd, + epochFeesUsd, + veBTCVotingPower: veBTCTotalVotingPower, + veMEZOVotingPower: veMEZOTotalVotingPower, + gaugeCount, + epochCountdown: timeRemaining, + epochWeek, + btcPrice, + mezoPrice, + isLoading, + } +} + +export default useAnalyticsKPIs diff --git a/apps/webapp/src/hooks/useEarnApiPools.ts b/apps/webapp/src/hooks/useEarnApiPools.ts new file mode 100644 index 0000000..65f5d51 --- /dev/null +++ b/apps/webapp/src/hooks/useEarnApiPools.ts @@ -0,0 +1,166 @@ +import { QUERY_PROFILES } from "@/config/queryProfiles" +import { useQuery } from "@tanstack/react-query" +import { z } from "zod" + +const TokenAmountUSDSchema = z.object({ + token: z + .object({ + address: z.string(), + symbol: z.string().nullable().optional(), + decimals: z.number().nullable().optional(), + }) + .passthrough(), + amount: z.string().optional(), + amountUSD: z.string().optional(), +}) + +const PoolTokenSchema = z + .object({ + address: z.string(), + symbol: z.string().nullable().optional(), + decimals: z.number().nullable().optional(), + reserve: z.string().optional(), + price: z.string().nullable().optional(), + }) + .passthrough() + +const PoolStatsSchema = z + .object({ + volume: z.array(TokenAmountUSDSchema).optional(), + fees: z.array(TokenAmountUSDSchema).optional(), + apr: z.number().optional(), + }) + .passthrough() + +const PoolSchema = z + .object({ + address: z.string(), + name: z.string(), + symbol: z.string().optional(), + type: z.enum(["basic", "concentrated"]).optional(), + token0: PoolTokenSchema, + token1: PoolTokenSchema, + tvl: z.string(), + matsBoost: z.number().optional(), + stats: PoolStatsSchema.optional(), + gauge: z.string().nullable().optional(), + volatility: z.string().optional(), + isVotable: z.boolean().optional(), + }) + .passthrough() + +const EarnApiResponseSchema = z.object({ + data: z.unknown(), + error: z.string().nullable().optional(), + endpoint: z.string().optional(), + timestamp: z.number().optional(), +}) + +// Upstream earn-api wraps arrays in { success, data: [...] } — handle both +// flat-array and wrapped responses. +const WrappedListSchema = z.union([ + z.array(PoolSchema), + z.object({ success: z.boolean().optional(), data: z.array(PoolSchema) }), +]) + +export type EarnApiPool = z.infer + +function sumTokenUsd( + amounts: z.infer[] | undefined, +): number { + if (!amounts || amounts.length === 0) return 0 + let total = 0 + for (const entry of amounts) { + const raw = entry.amountUSD ?? "0" + const numeric = Number(raw) + if (Number.isFinite(numeric)) total += numeric + } + return total +} + +export type PoolRow = { + address: string + name: string + type: "basic" | "concentrated" + tvlUsd: number + volumeUsd: number + feesUsd: number + aprBps: number + token0Symbol: string + token1Symbol: string + volatility: string + isVotable: boolean +} + +function normalizePool(pool: EarnApiPool): PoolRow { + const tvlNum = Number(pool.tvl) + const feesUsd = sumTokenUsd(pool.stats?.fees) + const volumeUsd = sumTokenUsd(pool.stats?.volume) + + return { + address: pool.address, + name: pool.name, + type: (pool.type ?? "basic") as "basic" | "concentrated", + tvlUsd: Number.isFinite(tvlNum) ? tvlNum : 0, + volumeUsd, + feesUsd, + aprBps: pool.stats?.apr ?? 0, + token0Symbol: pool.token0.symbol ?? "?", + token1Symbol: pool.token1.symbol ?? "?", + volatility: pool.volatility ?? "volatile", + isVotable: Boolean(pool.isVotable), + } +} + +async function fetchPools(): Promise<{ + pools: PoolRow[] + isUnavailable: boolean +}> { + const response = await fetch( + "/api/analytics/earn-proxy?endpoint=pools&timeframe=week", + ) + + if (!response.ok) { + return { pools: [], isUnavailable: true } + } + + const bodyRaw = await response.json() + const parsed = EarnApiResponseSchema.safeParse(bodyRaw) + if (!parsed.success) { + return { pools: [], isUnavailable: true } + } + + const body = parsed.data + if (body.error || body.data === null || body.data === undefined) { + return { pools: [], isUnavailable: true } + } + + const list = WrappedListSchema.safeParse(body.data) + if (!list.success) { + return { pools: [], isUnavailable: true } + } + + const rawPools = Array.isArray(list.data) ? list.data : list.data.data + return { + pools: rawPools.map(normalizePool), + isUnavailable: false, + } +} + +export function useEarnApiPools() { + const query = useQuery({ + queryKey: ["earn-api", "pools"], + queryFn: fetchPools, + ...QUERY_PROFILES.SHORT_CACHE, + retry: 1, + }) + + return { + pools: query.data?.pools ?? [], + isLoading: query.isLoading, + isUnavailable: query.data?.isUnavailable ?? query.isError, + refetch: query.refetch, + } +} + +export default useEarnApiPools diff --git a/apps/webapp/src/hooks/useGaugeVotesByEpoch.ts b/apps/webapp/src/hooks/useGaugeVotesByEpoch.ts new file mode 100644 index 0000000..c2369c1 --- /dev/null +++ b/apps/webapp/src/hooks/useGaugeVotesByEpoch.ts @@ -0,0 +1,62 @@ +import { QUERY_PROFILES } from "@/config/queryProfiles" +import { useQuery } from "@tanstack/react-query" + +export type GaugeVoteRow = { + gaugeAddress: string + vemezoWeight: string + vebtcWeight: string + totalIncentivesUsd: number + boostMultiplier: number | null +} + +export type EpochVotesBundle = { + epochStart: number + totalVemezoWeight: string + totalVebtcWeight: string + totalIncentivesUsd: number + gauges: GaugeVoteRow[] +} + +type ApiResponse = { + epochs: EpochVotesBundle[] + error?: string + timestamp: number +} + +async function fetchGaugeVotes(limit: number): Promise { + const response = await fetch( + `/api/analytics/gauge-votes-by-epoch?limit=${limit}`, + ) + + if (!response.ok) { + throw new Error(`Failed to fetch gauge votes: ${response.status}`) + } + + const body = (await response.json()) as ApiResponse + if (body.error) { + return [] + } + return body.epochs ?? [] +} + +/** + * Fetch per-gauge-per-epoch vote weights (veBTC + veMEZO) for the most recent + * `limit` epochs, ordered newest-first. Powers the gauge-votes breakdown and + * epoch time-travel UI. + */ +export function useGaugeVotesByEpoch(limit = 12) { + const query = useQuery({ + queryKey: ["gauge-votes-by-epoch", limit], + queryFn: () => fetchGaugeVotes(limit), + ...QUERY_PROFILES.LONG_CACHE, + }) + + return { + epochs: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + refetch: query.refetch, + } +} + +export default useGaugeVotesByEpoch diff --git a/apps/webapp/src/hooks/useProtocolHistory.ts b/apps/webapp/src/hooks/useProtocolHistory.ts new file mode 100644 index 0000000..4c20475 --- /dev/null +++ b/apps/webapp/src/hooks/useProtocolHistory.ts @@ -0,0 +1,59 @@ +import { QUERY_PROFILES } from "@/config/queryProfiles" +import { useQuery } from "@tanstack/react-query" + +export type HistoryPeriod = "1m" | "3m" | "all" + +export type ProtocolEpochDatum = { + epochStart: number + totalIncentivesUsd: number + gaugeCount: number + totalVemezoWeight: string + totalVebtcWeight: string + avgBoostMultiplier: number | null +} + +type ApiResponse = { + epochs: ProtocolEpochDatum[] + period?: string + error?: string + timestamp: number +} + +async function fetchProtocolHistory( + period: HistoryPeriod, +): Promise { + const response = await fetch( + `/api/analytics/gauge-history-aggregate?period=${period}`, + ) + + if (!response.ok) { + throw new Error(`Failed to fetch protocol history: ${response.status}`) + } + + const body = (await response.json()) as ApiResponse + if (body.error) { + return [] + } + return body.epochs ?? [] +} + +/** + * Fetch aggregated gauge_history data across all gauges, grouped by epoch. + * Powers the protocol revenue and earning power charts. + */ +export function useProtocolHistory(period: HistoryPeriod = "all") { + const query = useQuery({ + queryKey: ["protocol-history", period], + queryFn: () => fetchProtocolHistory(period), + ...QUERY_PROFILES.LONG_CACHE, + }) + + return { + epochs: query.data ?? [], + isLoading: query.isLoading, + isError: query.isError, + refetch: query.refetch, + } +} + +export default useProtocolHistory diff --git a/apps/webapp/src/pages/analytics.tsx b/apps/webapp/src/pages/analytics.tsx new file mode 100644 index 0000000..7ca1a6f --- /dev/null +++ b/apps/webapp/src/pages/analytics.tsx @@ -0,0 +1,46 @@ +import { InitialLoader } from "@/components/InitialLoader" +import { getAppUrl, getOgImageUrl } from "@/utils/seo" +import dynamic from "next/dynamic" +import Head from "next/head" + +const AnalyticsPage = dynamic( + () => import("@/components/pages/AnalyticsPage"), + { + ssr: false, + loading: () => , + }, +) + +export default function Analytics() { + const ogImageUrl = getOgImageUrl() + const pageUrl = getAppUrl("/analytics") + const title = "Analytics | Matchbox" + const description = + "Protocol performance, revenue, liquidity pools, and earning power analytics across the Mezo ecosystem." + + return ( + <> + + {title} + + + {/* Open Graph */} + + + + + + + + + {/* Twitter */} + + + + + + + + + ) +} diff --git a/apps/webapp/src/pages/api/analytics/earn-proxy.ts b/apps/webapp/src/pages/api/analytics/earn-proxy.ts new file mode 100644 index 0000000..78a43f4 --- /dev/null +++ b/apps/webapp/src/pages/api/analytics/earn-proxy.ts @@ -0,0 +1,135 @@ +export const config = { + runtime: "edge", +} + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", +} as const + +const EARN_API_BASE = "https://api.mezo.org" +const EARN_API_TESTNET_BASE = "https://api.testnet.mezo.org" + +// Whitelist of allowed upstream endpoints. Prevents the proxy from being +// used as an open relay. +const ALLOWED_ENDPOINTS = new Set([ + "pools", + "locks/stats", + "tokens", + "votes/votables", +]) + +const ALLOWED_QUERY_KEYS = new Set([ + "type", + "timeframe", + "filter", + "isVotable", + "endpoint", + "network", +]) + +function json(data: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(data), { + ...init, + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + ...init?.headers, + }, + }) +} + +export default async function handler(request: Request) { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }) + } + + const url = new URL(request.url) + const endpoint = url.searchParams.get("endpoint") + const network = url.searchParams.get("network") ?? "mainnet" + + if (!endpoint || !ALLOWED_ENDPOINTS.has(endpoint)) { + return json( + { + data: null, + error: "invalid-endpoint", + message: `endpoint must be one of: ${Array.from(ALLOWED_ENDPOINTS).join(", ")}`, + }, + { status: 400 }, + ) + } + + const base = network === "testnet" ? EARN_API_TESTNET_BASE : EARN_API_BASE + const upstreamUrl = new URL(`${base}/${endpoint}`) + + // Forward whitelisted query params. + for (const [key, value] of url.searchParams.entries()) { + if ( + ALLOWED_QUERY_KEYS.has(key) && + key !== "endpoint" && + key !== "network" + ) { + upstreamUrl.searchParams.set(key, value) + } + } + + try { + const response = await fetch(upstreamUrl.toString(), { + method: "GET", + headers: { accept: "application/json" }, + signal: AbortSignal.timeout(10_000), + }) + + if (!response.ok) { + return json( + { + data: null, + error: "upstream-unavailable", + status: response.status, + endpoint, + timestamp: Date.now(), + }, + { + status: 200, + headers: { + "Cache-Control": "public, s-maxage=10, stale-while-revalidate=30", + }, + }, + ) + } + + const body = await response.json() + + return json( + { + data: body, + error: null, + endpoint, + timestamp: Date.now(), + }, + { + status: 200, + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", + }, + }, + ) + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error" + return json( + { + data: null, + error: "upstream-unavailable", + message, + endpoint, + timestamp: Date.now(), + }, + { + status: 200, + headers: { + "Cache-Control": "public, s-maxage=10, stale-while-revalidate=30", + }, + }, + ) + } +} diff --git a/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts b/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts new file mode 100644 index 0000000..3436f6b --- /dev/null +++ b/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts @@ -0,0 +1,180 @@ +import { createClient } from "@supabase/supabase-js" + +export const config = { + runtime: "edge", +} + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", +} as const + +const SECONDS_PER_DAY = 86_400 + +function json(data: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(data), { + ...init, + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + ...init?.headers, + }, + }) +} + +type GaugeHistoryRow = { + epoch_start: number + total_incentives_usd: number | null + vemezo_weight: string | null + vebtc_weight: string | null + gauge_address: string + boost_multiplier: number | null +} + +type AggregatedEpoch = { + epochStart: number + totalIncentivesUsd: number + gaugeCount: number + totalVemezoWeight: string + totalVebtcWeight: string + avgBoostMultiplier: number | null +} + +function getCutoffEpoch(period: string): number { + const now = Math.floor(Date.now() / 1000) + if (period === "1m") return now - 30 * SECONDS_PER_DAY + if (period === "3m") return now - 90 * SECONDS_PER_DAY + return 0 +} + +export default async function handler(request: Request) { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }) + } + + const url = new URL(request.url) + const period = url.searchParams.get("period") ?? "all" + const cutoffEpoch = getCutoffEpoch(period) + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + + if (!supabaseUrl || !supabaseAnonKey) { + return json( + { + epochs: [], + error: "missing-supabase-config", + timestamp: Date.now(), + }, + { status: 200 }, + ) + } + + try { + const supabase = createClient(supabaseUrl, supabaseAnonKey) + + const { data, error } = await supabase + .from("gauge_history") + .select( + "epoch_start, total_incentives_usd, vemezo_weight, vebtc_weight, gauge_address, boost_multiplier", + ) + .gte("epoch_start", cutoffEpoch) + .order("epoch_start", { ascending: true }) + + if (error) { + return json( + { + epochs: [], + error: error.message, + timestamp: Date.now(), + }, + { status: 200 }, + ) + } + + const rows = (data ?? []) as unknown as GaugeHistoryRow[] + + // Aggregate per epoch_start. + const byEpoch = new Map< + number, + { + totalIncentivesUsd: number + gaugeAddresses: Set + totalVemezoWeight: bigint + totalVebtcWeight: bigint + boostSum: number + boostCount: number + } + >() + + for (const row of rows) { + const existing = byEpoch.get(row.epoch_start) ?? { + totalIncentivesUsd: 0, + gaugeAddresses: new Set(), + totalVemezoWeight: 0n, + totalVebtcWeight: 0n, + boostSum: 0, + boostCount: 0, + } + + existing.totalIncentivesUsd += Number(row.total_incentives_usd ?? 0) + existing.gaugeAddresses.add(row.gauge_address) + if (row.vemezo_weight) { + try { + existing.totalVemezoWeight += BigInt(row.vemezo_weight) + } catch { + // Skip malformed bigint values. + } + } + if (row.vebtc_weight) { + try { + existing.totalVebtcWeight += BigInt(row.vebtc_weight) + } catch { + // Skip malformed bigint values. + } + } + if (row.boost_multiplier !== null) { + existing.boostSum += Number(row.boost_multiplier) + existing.boostCount += 1 + } + + byEpoch.set(row.epoch_start, existing) + } + + const epochs: AggregatedEpoch[] = Array.from(byEpoch.entries()) + .sort(([a], [b]) => a - b) + .map(([epochStart, agg]) => ({ + epochStart, + totalIncentivesUsd: Number(agg.totalIncentivesUsd.toFixed(2)), + gaugeCount: agg.gaugeAddresses.size, + totalVemezoWeight: agg.totalVemezoWeight.toString(), + totalVebtcWeight: agg.totalVebtcWeight.toString(), + avgBoostMultiplier: + agg.boostCount > 0 ? agg.boostSum / agg.boostCount : null, + })) + + return json( + { + epochs, + period, + timestamp: Date.now(), + }, + { + status: 200, + headers: { + "Cache-Control": "public, s-maxage=120, stale-while-revalidate=600", + }, + }, + ) + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error" + return json( + { + epochs: [], + error: message, + timestamp: Date.now(), + }, + { status: 200 }, + ) + } +} diff --git a/apps/webapp/src/pages/api/analytics/gauge-votes-by-epoch.ts b/apps/webapp/src/pages/api/analytics/gauge-votes-by-epoch.ts new file mode 100644 index 0000000..a24d458 --- /dev/null +++ b/apps/webapp/src/pages/api/analytics/gauge-votes-by-epoch.ts @@ -0,0 +1,242 @@ +import { createClient } from "@supabase/supabase-js" + +export const config = { + runtime: "edge", +} + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", +} as const + +const DEFAULT_EPOCH_LIMIT = 12 +const MAX_EPOCH_LIMIT = 52 + +function json(data: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(data), { + ...init, + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + ...init?.headers, + }, + }) +} + +type GaugeHistoryRow = { + epoch_start: number + gauge_address: string + vemezo_weight: string | null + vebtc_weight: string | null + total_incentives_usd: number | null + boost_multiplier: number | null +} + +type GaugeVoteRow = { + gaugeAddress: string + vemezoWeight: string + vebtcWeight: string + totalIncentivesUsd: number + boostMultiplier: number | null +} + +type EpochVotesBundle = { + epochStart: number + totalVemezoWeight: string + totalVebtcWeight: string + totalIncentivesUsd: number + gauges: GaugeVoteRow[] +} + +export default async function handler(request: Request) { + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }) + } + + const url = new URL(request.url) + const rawLimit = Number(url.searchParams.get("limit") ?? DEFAULT_EPOCH_LIMIT) + const limit = Number.isFinite(rawLimit) + ? Math.min(Math.max(1, Math.floor(rawLimit)), MAX_EPOCH_LIMIT) + : DEFAULT_EPOCH_LIMIT + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY + + if (!supabaseUrl || !supabaseAnonKey) { + return json( + { + epochs: [], + error: "missing-supabase-config", + timestamp: Date.now(), + }, + { status: 200 }, + ) + } + + try { + const supabase = createClient(supabaseUrl, supabaseAnonKey) + + // First: find the most recent N distinct epoch_start values. + const { data: epochRows, error: epochError } = await supabase + .from("gauge_history") + .select("epoch_start") + .order("epoch_start", { ascending: false }) + .limit(limit * 400) // Heuristic: ~400 gauges * N epochs upper bound. + + if (epochError) { + return json( + { + epochs: [], + error: epochError.message, + timestamp: Date.now(), + }, + { status: 200 }, + ) + } + + const recentEpochs = Array.from( + new Set( + ((epochRows ?? []) as unknown as { epoch_start: number }[]).map( + (row) => row.epoch_start, + ), + ), + ) + .sort((a, b) => b - a) + .slice(0, limit) + + if (recentEpochs.length === 0) { + return json( + { + epochs: [], + timestamp: Date.now(), + }, + { status: 200 }, + ) + } + + const minEpoch = Math.min(...recentEpochs) + + const { data, error } = await supabase + .from("gauge_history") + .select( + "epoch_start, gauge_address, vemezo_weight, vebtc_weight, total_incentives_usd, boost_multiplier", + ) + .gte("epoch_start", minEpoch) + .order("epoch_start", { ascending: false }) + + if (error) { + return json( + { + epochs: [], + error: error.message, + timestamp: Date.now(), + }, + { status: 200 }, + ) + } + + const rows = (data ?? []) as unknown as GaugeHistoryRow[] + const epochSet = new Set(recentEpochs) + + const byEpoch = new Map< + number, + { + totalVemezoWeight: bigint + totalVebtcWeight: bigint + totalIncentivesUsd: number + gauges: Map + } + >() + + for (const row of rows) { + if (!epochSet.has(row.epoch_start)) continue + + const bucket = byEpoch.get(row.epoch_start) ?? { + totalVemezoWeight: 0n, + totalVebtcWeight: 0n, + totalIncentivesUsd: 0, + gauges: new Map(), + } + + let vemezoBig = 0n + if (row.vemezo_weight) { + try { + vemezoBig = BigInt(row.vemezo_weight) + } catch { + // skip malformed + } + } + + let vebtcBig = 0n + if (row.vebtc_weight) { + try { + vebtcBig = BigInt(row.vebtc_weight) + } catch { + // skip malformed + } + } + + const incentives = Number(row.total_incentives_usd ?? 0) + + bucket.totalVemezoWeight += vemezoBig + bucket.totalVebtcWeight += vebtcBig + bucket.totalIncentivesUsd += incentives + + const gaugeKey = row.gauge_address.toLowerCase() + const existing = bucket.gauges.get(gaugeKey) + if (existing) { + // Should not happen: one row per (gauge, epoch). If it does, sum defensively. + existing.vemezoWeight = ( + BigInt(existing.vemezoWeight) + vemezoBig + ).toString() + existing.vebtcWeight = ( + BigInt(existing.vebtcWeight) + vebtcBig + ).toString() + existing.totalIncentivesUsd += incentives + } else { + bucket.gauges.set(gaugeKey, { + gaugeAddress: gaugeKey, + vemezoWeight: vemezoBig.toString(), + vebtcWeight: vebtcBig.toString(), + totalIncentivesUsd: Number(incentives.toFixed(2)), + boostMultiplier: row.boost_multiplier, + }) + } + + byEpoch.set(row.epoch_start, bucket) + } + + const epochs: EpochVotesBundle[] = Array.from(byEpoch.entries()) + .sort(([a], [b]) => b - a) + .map(([epochStart, bucket]) => ({ + epochStart, + totalVemezoWeight: bucket.totalVemezoWeight.toString(), + totalVebtcWeight: bucket.totalVebtcWeight.toString(), + totalIncentivesUsd: Number(bucket.totalIncentivesUsd.toFixed(2)), + gauges: Array.from(bucket.gauges.values()), + })) + + return json( + { + epochs, + timestamp: Date.now(), + }, + { + status: 200, + headers: { + "Cache-Control": "public, s-maxage=120, stale-while-revalidate=600", + }, + }, + ) + } catch (err) { + const message = err instanceof Error ? err.message : "unknown error" + return json( + { + epochs: [], + error: message, + timestamp: Date.now(), + }, + { status: 200 }, + ) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1add1b7..3528ecb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + recharts: + specifier: ^2.15.0 + version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) styletron-engine-monolithic: specifier: ^1.0.0 version: 1.0.0 @@ -3210,6 +3213,33 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -4304,6 +4334,10 @@ packages: d3-array@2.4.0: resolution: {integrity: sha512-KQ41bAF2BMakf/HdKT865ALd4cgND6VcIztVQZUTt0+BH3RWy6ZYnHghVXf6NFjt2ritLr8H1T8LreAAlfiNcw==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + d3-axis@2.1.0: resolution: {integrity: sha512-z/G2TQMyuf0X3qP+Mh+2PimoJD41VOCjViJzT0BHeL/+JQAofkiWZbWxlwFGb1N8EN+Cl/CW+MUKbVzr1689Cw==} @@ -4335,6 +4369,10 @@ packages: d3-ease@2.0.0: resolution: {integrity: sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==} + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + d3-fetch@2.0.0: resolution: {integrity: sha512-TkYv/hjXgCryBeNKiclrwqZH7Nb+GaOwo3Neg24ZVWA3MKB+Rd+BY84Nh6tmNEMcjUik1CSUWjXYndmeO6F7sw==} @@ -4353,9 +4391,17 @@ packages: d3-interpolate@2.0.1: resolution: {integrity: sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==} + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + d3-path@2.0.0: resolution: {integrity: sha512-ZwZQxKhBnv9yHaiWd6ZU4x5BtCQ7pXszEV9CU6kRgwIQVQGLMv1oiL4M+MK/n79sYzsj+gcgpPQSctJUsLN7fA==} + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + d3-polygon@2.0.0: resolution: {integrity: sha512-MsexrCK38cTGermELs0cO1d79DcTsQRN7IWMJKczD/2kBjzNXxLUWP33qRF6VDpiLV/4EI4r6Gs0DAWQkE8pSQ==} @@ -4371,21 +4417,37 @@ packages: d3-scale@3.3.0: resolution: {integrity: sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==} + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + d3-selection@2.0.0: resolution: {integrity: sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==} d3-shape@2.1.0: resolution: {integrity: sha512-PnjUqfM2PpskbSLTJvAzp2Wv4CZsnAgTfcVRTwW03QR3MkXF8Uo7B1y/lWkAsmbKwuecto++4NlsYcvYpXpTHA==} + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + d3-time-format@3.0.0: resolution: {integrity: sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag==} d3-time@2.1.1: resolution: {integrity: sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==} + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + d3-timer@2.0.0: resolution: {integrity: sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==} + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + d3-transition@2.0.0: resolution: {integrity: sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==} peerDependencies: @@ -4453,6 +4515,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decode-uri-component@0.2.2: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} @@ -4879,6 +4944,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -6752,6 +6821,12 @@ packages: '@types/react': optional: true + react-smooth@4.0.4: + resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -6762,6 +6837,12 @@ packages: '@types/react': optional: true + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react-uid@2.3.0: resolution: {integrity: sha512-tsPZ77GR0pISGYmpCLHAbZTabKXZ7zBniKPVqVMMfnXFyo39zq5g/psIlD5vLTKkjQEhWOO8JhqcHnxkwNu6eA==} engines: {node: '>=8.5.0'} @@ -6826,6 +6907,16 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + + recharts@2.15.4: + resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} + engines: {node: '>=14'} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -7757,6 +7848,9 @@ packages: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} engines: {'0': node >=0.6.0} + victory-vendor@36.9.2: + resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + viem@2.23.2: resolution: {integrity: sha512-NVmW/E0c5crMOtbEAqMF0e3NmvQykFXhLOc/CkLIXOlzHSA6KXVz3CYVmaKqBF8/xtjsjHAGjdJN3Ru1kFJLaA==} peerDependencies: @@ -9389,22 +9483,22 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.28.5)': dependencies: @@ -9414,52 +9508,52 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': dependencies: @@ -12766,6 +12860,30 @@ snapshots: dependencies: '@types/node': 22.19.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -13834,7 +13952,7 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.27.1 + '@babel/helper-plugin-utils': 7.28.6 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.3 istanbul-lib-instrument: 5.2.1 @@ -14484,6 +14602,10 @@ snapshots: d3-array@2.4.0: {} + d3-array@3.2.4: + dependencies: + internmap: 1.0.1 + d3-axis@2.1.0: {} d3-brush@2.1.0: @@ -14523,6 +14645,8 @@ snapshots: d3-ease@2.0.0: {} + d3-ease@3.0.1: {} + d3-fetch@2.0.0: dependencies: d3-dsv: 2.0.0 @@ -14545,8 +14669,14 @@ snapshots: dependencies: d3-color: 2.0.0 + d3-interpolate@3.0.1: + dependencies: + d3-color: 2.0.0 + d3-path@2.0.0: {} + d3-path@3.1.0: {} + d3-polygon@2.0.0: {} d3-quadtree@2.0.0: {} @@ -14566,12 +14696,24 @@ snapshots: d3-time: 2.1.1 d3-time-format: 3.0.0 + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 2.0.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 3.0.0 + d3-selection@2.0.0: {} d3-shape@2.1.0: dependencies: d3-path: 2.0.0 + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + d3-time-format@3.0.0: dependencies: d3-time: 2.1.1 @@ -14580,8 +14722,14 @@ snapshots: dependencies: d3-array: 2.12.1 + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + d3-timer@2.0.0: {} + d3-timer@3.0.1: {} + d3-transition@2.0.0(d3-selection@2.0.0): dependencies: d3-color: 2.0.0 @@ -14669,6 +14817,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} + decode-uri-component@0.2.2: {} decompress-response@3.3.0: @@ -15273,6 +15423,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -17380,6 +17532,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 + react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + fast-equals: 5.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-style-singleton@2.2.3(@types/react@18.3.27)(react@18.3.1): dependencies: get-nonce: 1.0.1 @@ -17388,6 +17548,15 @@ snapshots: optionalDependencies: '@types/react': 18.3.27 + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-uid@2.3.0(@types/react@18.3.27)(react@18.3.1): dependencies: react: 18.3.1 @@ -17460,6 +17629,23 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts-scale@0.4.5: + dependencies: + decimal.js-light: 2.5.1 + + recharts@2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 2.1.1 + eventemitter3: 4.0.4 + lodash: 4.17.21 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.3.1 + react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.3 + victory-vendor: 36.9.2 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -18466,6 +18652,23 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.3.0 + victory-vendor@36.9.2: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + viem@2.23.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76): dependencies: '@noble/curves': 1.8.1