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 (
+
+
+
+
+
+
+
+ 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 (
+
+
+
+
+
+
+
+
+
+ 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 (
+
+
+
+
+ {isLoading && rows.length === 0 ? (
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+ ) : rows.length === 0 ? (
+
+ $ no gauges match your
+ search
+
+ ) : (
+
+
+
+
+ |
+
+ Gauge
+
+ |
+
+
+ veMEZO
+
+ |
+
+
+ Boost
+
+ |
+
+
+ APY
+
+ |
+
+
+ Incentives
+
+ |
+
+
+
+ {paginatedItems.map(({ gauge, apyData, displayName }) => (
+
+ |
+
+
+ {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
+
+ ) : (
+
+
+
+
+ |
+
+ Pool
+
+ |
+
+ TVL
+ |
+
+ Volume
+ |
+
+ Fees
+ |
+
+ APR
+ |
+
+
+
+ {paginatedItems.map((pool) => (
+
+
+
+
+ {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 (
+
+
+
+
+
+ {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 (
+
+ )
+}
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