From e51d5c3229d5898e577c3f71df61a8b56b487909 Mon Sep 17 00:00:00 2001 From: William Pyke Date: Tue, 21 Apr 2026 10:19:48 -0400 Subject: [PATCH 1/9] Add Analytics page with protocol KPIs, revenue chart, and gauges New /analytics route with five sections: KPI cards (TVL, locked BTC, epoch fees, voting power), historical protocol revenue bar chart from Supabase gauge_history, sortable/searchable gauges table, liquidity pools table with graceful earn-api fallback, and earning power area chart. Includes two new edge API routes, three data-fetching hooks using React Query, and a nav link in the header. Co-Authored-By: Claude Sonnet 4.6 --- apps/webapp/package.json | 1 + apps/webapp/src/components/Header.tsx | 1 + .../analytics/AnalyticsEarningPower.tsx | 248 +++++++++++++++ .../analytics/AnalyticsGaugesTable.tsx | 287 ++++++++++++++++++ .../components/analytics/AnalyticsKPIBar.tsx | 214 +++++++++++++ .../analytics/AnalyticsPoolsTable.tsx | 276 +++++++++++++++++ .../analytics/AnalyticsRevenueChart.tsx | 217 +++++++++++++ .../src/components/pages/AnalyticsPage.tsx | 26 ++ apps/webapp/src/hooks/useAnalyticsKPIs.ts | 125 ++++++++ apps/webapp/src/hooks/useEarnApiPools.ts | 166 ++++++++++ apps/webapp/src/hooks/useProtocolHistory.ts | 58 ++++ apps/webapp/src/pages/analytics.tsx | 46 +++ .../src/pages/api/analytics/earn-proxy.ts | 135 ++++++++ .../api/analytics/gauge-history-aggregate.ts | 168 ++++++++++ 14 files changed, 1968 insertions(+) create mode 100644 apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx create mode 100644 apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx create mode 100644 apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx create mode 100644 apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx create mode 100644 apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx create mode 100644 apps/webapp/src/components/pages/AnalyticsPage.tsx create mode 100644 apps/webapp/src/hooks/useAnalyticsKPIs.ts create mode 100644 apps/webapp/src/hooks/useEarnApiPools.ts create mode 100644 apps/webapp/src/hooks/useProtocolHistory.ts create mode 100644 apps/webapp/src/pages/analytics.tsx create mode 100644 apps/webapp/src/pages/api/analytics/earn-proxy.ts create mode 100644 apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts 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..75f0621 --- /dev/null +++ b/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx @@ -0,0 +1,248 @@ +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 datum = payload[0].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/AnalyticsGaugesTable.tsx b/apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx new file mode 100644 index 0000000..541f48c --- /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..1d2abc2 --- /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 + accent?: "brand" | "positive" | "amber" | "cyan" + isLoading?: boolean +} + +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..c5d0ba6 --- /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..ab8b0f6 --- /dev/null +++ b/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx @@ -0,0 +1,217 @@ +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 datum = payload[0].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..dfe8c53 --- /dev/null +++ b/apps/webapp/src/components/pages/AnalyticsPage.tsx @@ -0,0 +1,26 @@ +import { AnalyticsEarningPower } from "@/components/analytics/AnalyticsEarningPower" +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/useProtocolHistory.ts b/apps/webapp/src/hooks/useProtocolHistory.ts new file mode 100644 index 0000000..e546f21 --- /dev/null +++ b/apps/webapp/src/hooks/useProtocolHistory.ts @@ -0,0 +1,58 @@ +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 + 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..792b745 --- /dev/null +++ b/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts @@ -0,0 +1,168 @@ +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 + gauge_address: string + boost_multiplier: number | null +} + +type AggregatedEpoch = { + epochStart: number + totalIncentivesUsd: number + gaugeCount: number + totalVemezoWeight: 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, 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 GaugeHistoryRow[] + + // Aggregate per epoch_start. + const byEpoch = new Map< + number, + { + totalIncentivesUsd: number + gaugeAddresses: Set + totalVemezoWeight: bigint + boostSum: number + boostCount: number + } + >() + + for (const row of rows) { + const existing = byEpoch.get(row.epoch_start) ?? { + totalIncentivesUsd: 0, + gaugeAddresses: new Set(), + totalVemezoWeight: 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.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(), + 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 }, + ) + } +} From 8a17cd586aee40ed3446b6599c38f504ae61ef4a Mon Sep 17 00:00:00 2001 From: William Pyke Date: Tue, 21 Apr 2026 10:25:38 -0400 Subject: [PATCH 2/9] Update pnpm-lock.yaml for recharts dependency Regenerates the lockfile so CI's frozen-lockfile install succeeds after adding recharts to apps/webapp/package.json. Co-Authored-By: Claude Sonnet 4.6 --- pnpm-lock.yaml | 233 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 218 insertions(+), 15 deletions(-) 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 From 9513bc48472649a2fdb2eb20ece9054155d2e03c Mon Sep 17 00:00:00 2001 From: William Pyke Date: Tue, 21 Apr 2026 10:32:25 -0400 Subject: [PATCH 3/9] Guard tooltip payload[0] against undefined Next.js strict build flags payload[0].payload as possibly undefined under noUncheckedIndexedAccess. Narrow the first entry explicitly before reading .payload so both analytics tooltips type-check. Co-Authored-By: Claude Sonnet 4.6 --- .../webapp/src/components/analytics/AnalyticsEarningPower.tsx | 4 +++- .../webapp/src/components/analytics/AnalyticsRevenueChart.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx b/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx index 75f0621..f546b92 100644 --- a/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx @@ -60,7 +60,9 @@ type TooltipProps = { function CustomTooltip({ active, payload }: TooltipProps) { if (!active || !payload || payload.length === 0) return null - const datum = payload[0].payload + const first = payload[0] + if (!first) return null + const datum = first.payload const date = new Date(datum.epochStart * 1000) return ( diff --git a/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx b/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx index ab8b0f6..c254915 100644 --- a/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx @@ -47,7 +47,9 @@ type TooltipProps = { function CustomTooltip({ active, payload }: TooltipProps) { if (!active || !payload || payload.length === 0) return null - const datum = payload[0].payload + const first = payload[0] + if (!first) return null + const datum = first.payload const date = new Date(datum.raw.epochStart * 1000) return ( From a66b03d27ed070b60fe4878fcc6a6c2b3940d545 Mon Sep 17 00:00:00 2001 From: William Pyke Date: Tue, 21 Apr 2026 11:51:06 -0400 Subject: [PATCH 4/9] Pass required animation prop to Skeleton in analytics components Skeleton from @mezo-org/mezo-clay requires the animation prop; other pages (Dashboard, Incentives, Boost) already pass it. Add it to all seven Skeleton usages across the analytics components so the Next.js type-check during build succeeds. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/analytics/AnalyticsEarningPower.tsx | 6 +++--- .../src/components/analytics/AnalyticsGaugesTable.tsx | 2 +- apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx | 2 +- .../webapp/src/components/analytics/AnalyticsPoolsTable.tsx | 2 +- .../src/components/analytics/AnalyticsRevenueChart.tsx | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx b/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx index f546b92..2192c53 100644 --- a/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsEarningPower.tsx @@ -123,7 +123,7 @@ export function AnalyticsEarningPower(): JSX.Element {
{isLoadingVoter && veBTCTotalVotingPower === undefined ? ( - + ) : (
{formatCompactNumber(veBTCTotalVotingPower)} @@ -140,7 +140,7 @@ export function AnalyticsEarningPower(): JSX.Element {
{isLoadingVoter && veMEZOTotalVotingPower === undefined ? ( - + ) : (
{formatCompactNumber(veMEZOTotalVotingPower)} @@ -160,7 +160,7 @@ export function AnalyticsEarningPower(): JSX.Element {
{isLoadingHistory ? (
- +
) : !hasChartData ? (
diff --git a/apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx b/apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx index 541f48c..3175ac3 100644 --- a/apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsGaugesTable.tsx @@ -181,7 +181,7 @@ export function AnalyticsGaugesTable(): JSX.Element { {isLoading && rows.length === 0 ? (
{[0, 1, 2, 3].map((i) => ( - + ))}
) : rows.length === 0 ? ( diff --git a/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx b/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx index 1d2abc2..1c038d2 100644 --- a/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx @@ -133,7 +133,7 @@ function KPICard({
{isLoading ? ( - + ) : (
{value} diff --git a/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx b/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx index c5d0ba6..9fbbc02 100644 --- a/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx @@ -160,7 +160,7 @@ export function AnalyticsPoolsTable(): JSX.Element { {isLoading ? (
{[0, 1, 2, 3].map((i) => ( - + ))}
) : isUnavailable ? ( diff --git a/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx b/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx index c254915..1ebe85b 100644 --- a/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsRevenueChart.tsx @@ -122,7 +122,7 @@ export function AnalyticsRevenueChart(): JSX.Element {
{isLoading ? ( - + ) : (
{formatCompactUsd(totalUsd)} @@ -154,7 +154,7 @@ export function AnalyticsRevenueChart(): JSX.Element {
{isLoading ? (
- +
) : chartData.length === 0 ? (
From 5d82182c3f487257a5cae720cb268697863b3af8 Mon Sep 17 00:00:00 2001 From: William Pyke Date: Tue, 21 Apr 2026 12:02:19 -0400 Subject: [PATCH 5/9] Allow explicit undefined on KPICard optional props tsconfig has exactOptionalPropertyTypes enabled, so sublabel?: string rejects the ternary 'kpis.btcPrice ? "@ $..." : undefined' expression passed from the BTC card. Widen sublabel and isLoading to include | undefined so explicit undefined is accepted. Co-Authored-By: Claude Sonnet 4.6 --- apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx b/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx index 1c038d2..055a8cf 100644 --- a/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsKPIBar.tsx @@ -103,9 +103,9 @@ type KPICardProps = { icon: React.ReactNode label: string value: string - sublabel?: string + sublabel?: string | undefined accent?: "brand" | "positive" | "amber" | "cyan" - isLoading?: boolean + isLoading?: boolean | undefined } const ACCENT_STYLES: Record, string> = { From a2e922f9333cc3c33e32b115fce74a4a3092643e Mon Sep 17 00:00:00 2001 From: William Pyke Date: Tue, 21 Apr 2026 12:09:10 -0400 Subject: [PATCH 6/9] Swap invalid Tag color 'orange' for 'yellow' on pool type The Tag component's color prop only accepts blue/brown/gray/green/ purple/red/yellow. Use yellow as the accent for concentrated pools so the build type-checks. Co-Authored-By: Claude Sonnet 4.6 --- apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx b/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx index 9fbbc02..913f9e4 100644 --- a/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx @@ -217,7 +217,7 @@ export function AnalyticsPoolsTable(): JSX.Element { {pool.type === "concentrated" ? "CL" : "AMM"} From 0814512502149d3c8842b3d2778cb009f2859e18 Mon Sep 17 00:00:00 2001 From: William Pyke Date: Tue, 21 Apr 2026 12:11:21 -0400 Subject: [PATCH 7/9] Replace invalid Tag kind prop with closeable on pools table The Tag component from @mezo-org/mezo-clay exposes closeable, not kind. Match the pattern used in GaugeCard and LockCarouselSelector by passing closeable={false} on all three pool-type Tags. Co-Authored-By: Claude Sonnet 4.6 --- .../webapp/src/components/analytics/AnalyticsPoolsTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx b/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx index 913f9e4..7cf59a8 100644 --- a/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx +++ b/apps/webapp/src/components/analytics/AnalyticsPoolsTable.tsx @@ -215,7 +215,7 @@ export function AnalyticsPoolsTable(): JSX.Element {
{pool.volatility === "stable" && ( - + stable )} {pool.isVotable && ( - + votable )} From 36b87cdabd718667139e8eb1f1c8712b0e121ec9 Mon Sep 17 00:00:00 2001 From: William Pyke Date: Tue, 21 Apr 2026 12:14:41 -0400 Subject: [PATCH 8/9] Route Supabase rows through unknown for GaugeHistoryRow cast The gauge_history table is untyped in the generated Supabase types, so Supabase infers SelectQueryError for the select() result. Route the cast through unknown (as the compiler suggests) so the assertion to GaugeHistoryRow[] type-checks. Co-Authored-By: Claude Sonnet 4.6 --- apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts b/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts index 792b745..f112bf2 100644 --- a/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts +++ b/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts @@ -90,7 +90,7 @@ export default async function handler(request: Request) { ) } - const rows = (data ?? []) as GaugeHistoryRow[] + const rows = (data ?? []) as unknown as GaugeHistoryRow[] // Aggregate per epoch_start. const byEpoch = new Map< From 4f31cd64acb5ee30b3e33419be4a1f944d41fa87 Mon Sep 17 00:00:00 2001 From: William Pyke Date: Wed, 22 Apr 2026 00:20:56 -0400 Subject: [PATCH 9/9] Add gauge vote share analytics with epoch time-travel Introduces a new AnalyticsGaugeVotes section that surfaces per-gauge veBTC/veMEZO vote allocations across every registered gauge (LP pools, MUSD/BTC, MUSD Savings Rate, etc.) so voters can compare relative weight at a glance. - New edge API /api/analytics/gauge-votes-by-epoch reads per-gauge rows from gauge_history for the last N epochs and returns both vebtc_weight and vemezo_weight per gauge per epoch. - New useGaugeVotesByEpoch hook wraps the endpoint with the LONG_CACHE React Query profile. - New AnalyticsGaugeVotes component adds: * Toggle between veBTC and veMEZO vote basis * Epoch selector (prev/next buttons + inline chip strip) for time-travel across the last 12 epochs * Per-gauge percentage bars with delta-vs-previous-epoch column * "new" / "dropped" tags for gauges entering/leaving the set * KPI strip with total weight, active gauges, epoch incentives, and top gauge share - gauge-history-aggregate now also aggregates vebtc_weight per epoch so downstream charts can pivot on BTC-backed vote totals. Co-Authored-By: Claude Opus 4.7 --- .../analytics/AnalyticsGaugeVotes.tsx | 478 ++++++++++++++++++ .../src/components/pages/AnalyticsPage.tsx | 2 + apps/webapp/src/hooks/useGaugeVotesByEpoch.ts | 62 +++ apps/webapp/src/hooks/useProtocolHistory.ts | 1 + .../api/analytics/gauge-history-aggregate.ts | 14 +- .../api/analytics/gauge-votes-by-epoch.ts | 242 +++++++++ 6 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 apps/webapp/src/components/analytics/AnalyticsGaugeVotes.tsx create mode 100644 apps/webapp/src/hooks/useGaugeVotesByEpoch.ts create mode 100644 apps/webapp/src/pages/api/analytics/gauge-votes-by-epoch.ts 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/pages/AnalyticsPage.tsx b/apps/webapp/src/components/pages/AnalyticsPage.tsx index dfe8c53..d9af49d 100644 --- a/apps/webapp/src/components/pages/AnalyticsPage.tsx +++ b/apps/webapp/src/components/pages/AnalyticsPage.tsx @@ -1,4 +1,5 @@ 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" @@ -18,6 +19,7 @@ export default function AnalyticsPage(): JSX.Element { + 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 index e546f21..4c20475 100644 --- a/apps/webapp/src/hooks/useProtocolHistory.ts +++ b/apps/webapp/src/hooks/useProtocolHistory.ts @@ -8,6 +8,7 @@ export type ProtocolEpochDatum = { totalIncentivesUsd: number gaugeCount: number totalVemezoWeight: string + totalVebtcWeight: string avgBoostMultiplier: number | null } diff --git a/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts b/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts index f112bf2..3436f6b 100644 --- a/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts +++ b/apps/webapp/src/pages/api/analytics/gauge-history-aggregate.ts @@ -26,6 +26,7 @@ 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 } @@ -35,6 +36,7 @@ type AggregatedEpoch = { totalIncentivesUsd: number gaugeCount: number totalVemezoWeight: string + totalVebtcWeight: string avgBoostMultiplier: number | null } @@ -74,7 +76,7 @@ export default async function handler(request: Request) { const { data, error } = await supabase .from("gauge_history") .select( - "epoch_start, total_incentives_usd, vemezo_weight, gauge_address, boost_multiplier", + "epoch_start, total_incentives_usd, vemezo_weight, vebtc_weight, gauge_address, boost_multiplier", ) .gte("epoch_start", cutoffEpoch) .order("epoch_start", { ascending: true }) @@ -99,6 +101,7 @@ export default async function handler(request: Request) { totalIncentivesUsd: number gaugeAddresses: Set totalVemezoWeight: bigint + totalVebtcWeight: bigint boostSum: number boostCount: number } @@ -109,6 +112,7 @@ export default async function handler(request: Request) { totalIncentivesUsd: 0, gaugeAddresses: new Set(), totalVemezoWeight: 0n, + totalVebtcWeight: 0n, boostSum: 0, boostCount: 0, } @@ -122,6 +126,13 @@ export default async function handler(request: Request) { // 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 @@ -137,6 +148,7 @@ export default async function handler(request: Request) { 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, })) 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 }, + ) + } +}