From 51c455d0e98348900067f42ac4ee912a3e019325 Mon Sep 17 00:00:00 2001 From: allhandghost Date: Tue, 29 Sep 2026 19:48:18 +0000 Subject: [PATCH] =?UTF-8?q?feat(governance,analytics):=20implement=20#469?= =?UTF-8?q?=20#470=20#471=20#468=20=E2=80=94=20API=20layer,=20on-chain=20v?= =?UTF-8?q?oting,=20voting=20power,=20market-share=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue #469 — Replace in-memory governance mock with API-backed data layer ### Problem src/lib/governanceStore.ts held hard-coded proposals and comments in module-level mutable state, meaning comments vanished on reload and were shared across all server renders. Components called the store synchronously which could not represent loading or error states. ### What was done - Created src/lib/governanceApi.ts with a typed GovernanceApi interface (listProposals, getProposal, listComments, postComment). - Implemented HttpGovernanceApi: fetches from relay endpoints (GET /api/governance/proposals, GET /api/governance/proposals/:id, GET /api/governance/proposals/:id/comments, POST /api/governance/proposals/:id/comments). Validates all responses with runtime type guards; throws GovernanceValidationError on shape mismatches. - Implemented MockGovernanceApi: uses the same deterministic seed data as the old store but with per-instance isolated comment stores — no shared mutable module state between tests. - Adapter factory (createGovernanceApi) selects the implementation from NEXT_PUBLIC_GOVERNANCE_SOURCE ('mock'|'http'); production defaults to 'http' and throws at construction time if NEXT_PUBLIC_API_URL is unset. - Created src/lib/governanceApiContext.tsx: React context + provider so tests can inject fake adapters without monkey-patching modules. - Created src/hooks/useGovernance.ts with SWR hooks: useProposals(filters), useProposal(id), useProposalComments(id). useProposalComments includes optimistic comment posting with rollback via SWR mutate + rollbackOnError. - Migrated GovernancePageClient.tsx: removed direct governanceStore imports, replaced with useProposals(); added loading skeleton, error state, and empty state. - Added src/app/governance/loading.tsx: Next.js loading UI with animated skeletons while proposals fetch. - Added src/app/governance/error.tsx: Next.js error boundary with a 'Try again' reset button. - Added sanitizeText() and validateCommentText() to src/lib/textSafety.ts (previously missing despite being imported). ## Issue #470 — On-chain voting: cast For/Against/Abstain with flow states ### Problem The governance page listed proposals but users could not vote. Governance without participation is just a dashboard. ### What was done - Created src/hooks/useVote.ts implementing the full voting flow state machine: idle → building → reviewing → awaiting-signature → submitting → confirmed, with an 'error' escape state at each step. - Guards enforced before flow starts: wallet disconnected, wrong network (checked against NEXT_PUBLIC_NETWORK), zero voting power, deadline passed (with 30-second server-time tolerance), no choice selected. - Optimistic tally delta applied immediately after the user confirms, flagged as 'pending: true' until the transaction is confirmed. On failure the delta is rolled back. - Double-vote handling: after confirmation confirmedChoice is set and the SWR cache for the proposal is invalidated to fetch authoritative tallies. - VoteChoice typed as 'for' | 'against' | 'abstain'; VoteErrorKind discriminates: wallet-disconnected, wrong-network, zero-power, deadline-passed, already-voted, user-rejected, network, generic. - classifyVoteError maps raw Error messages to VoteErrorKind. - Created src/components/VotePanel.tsx: radio group (For/Against/Abstain with ▲/▼ accessible labels), tally progress bar with optimistic overlay, finality copy ('Votes are final'), multi-step status label, submit button with aria-busy, error dismiss control. - VotePanel integrated into ProposalDetailClient: shown only for active proposals, gated behind wallet connect for inactive wallet state. ## Issue #471 — Voting-power hydration with snapshot semantics ### Problem Users needed to know before voting whether they could vote and with what weight. Snapshot semantics (power frozen at proposal creation) are a common source of confusion. ### What was done - Created src/hooks/useVotingPower.ts: useVotingPower(address, proposalId) → { power, isLoading, error, canVote } Power shape: { own, delegatedIn, delegatedOut, total, snapshotAt }. SWR cache keyed by [address, proposalId]; a wallet address change automatically invalidates the cache (different key). Adapter duck-typed: if the GovernanceApi instance exposes getVotingPower it is called; otherwise a graceful zero-power fallback is returned so the page renders without blocking. - formatVotingPower(amount) helper: uses compact notation for values ≥ 1,000 (e.g. '125K') with the full precise value on title/aria-label for hover/focus (WCAG accessible). - Created src/components/VotingPowerPanel.tsx: shows total weight, own / delegatedIn / delegatedOut breakdown (hidden when all zero), snapshot date tooltip explaining semantics, and a 0-power guidance block explaining how to acquire tokens or request delegation. - VotingPowerPanel integrated into ProposalDetailClient sidebar, visible when wallet is connected. ## Issue #468 — Market-share and top-movers analytics ### Problem Current analytics only showed point-in-time snapshots. Product and community teams needed to see shifts (which chain is growing, whether one solver is becoming dominant) for governance and growth decisions. ### What was done - Added computeShareSeries(intents, dimension) to src/lib/analytics.ts: Buckets intents by day; identifies top-N (5) entities by total volume; collapses remainder into 'Other' (deterministic). For each day computes per-entity share as a % of that day's total volume (zero-volume days produce 0 share — never NaN). Returns { entities, series, colors }. - Added computeConcentration(intents, dimension) to analytics.ts: Computes Herfindahl–Hirschman Index (HHI = Σ share_i²) scaled 0–10,000. Returns hhi, top1SharePct, top3SharePct, level (competitive/moderate/ concentrated), and ranked entity list. - Added computeTopMovers(intents, dimension, windowDays=7): compares share in the last N days vs the preceding N-day window; returns entities ranked by absolute delta with ▲/▼ accessible labels. - Chain colours sourced from CHAINS registry; solver/token colours use an 8-colour accessible fallback palette; 'Other' always maps to gray. - Added StackedAreaChart to AnalyticsPageClient: SVG stacked area with 100%-normalised toggle, per-entity fill opacity, grid lines, and legend. - Added TopMoversPanel: entity, current share %, delta in percentage points with ▲/▼ accessible aria-labels. - Added ConcentrationIndicator: HHI badge in a Tooltip explaining the index, top-1/top-3 share cards, top-5 entity mini-list. - Added dimension selector (Source Chain / Dest Token / Solver) and normalise toggle in AnalyticsPageClient controlling all three new panels. Closes #469 Closes #470 Closes #471 Closes #468 --- src/app/analytics/AnalyticsPageClient.tsx | 314 ++++++++++++++- src/app/governance/GovernancePageClient.tsx | 147 +++++--- .../governance/[id]/ProposalDetailClient.tsx | 356 +++++++++++------- src/app/governance/error.tsx | 60 +++ src/app/governance/loading.tsx | 45 +++ src/components/VotePanel.tsx | 252 +++++++++++++ src/components/VotingPowerPanel.tsx | 143 +++++++ src/hooks/useGovernance.ts | 152 ++++++++ src/hooks/useVote.ts | 275 ++++++++++++++ src/hooks/useVotingPower.ts | 176 +++++++++ src/lib/analytics.ts | 316 ++++++++++++++++ src/lib/governanceApi.ts | 345 +++++++++++++++++ src/lib/governanceApiContext.tsx | 36 ++ src/lib/textSafety.ts | 35 ++ 14 files changed, 2462 insertions(+), 190 deletions(-) create mode 100644 src/app/governance/error.tsx create mode 100644 src/app/governance/loading.tsx create mode 100644 src/components/VotePanel.tsx create mode 100644 src/components/VotingPowerPanel.tsx create mode 100644 src/hooks/useGovernance.ts create mode 100644 src/hooks/useVote.ts create mode 100644 src/hooks/useVotingPower.ts create mode 100644 src/lib/governanceApi.ts create mode 100644 src/lib/governanceApiContext.tsx diff --git a/src/app/analytics/AnalyticsPageClient.tsx b/src/app/analytics/AnalyticsPageClient.tsx index b232684..0bdfa9a 100644 --- a/src/app/analytics/AnalyticsPageClient.tsx +++ b/src/app/analytics/AnalyticsPageClient.tsx @@ -1,11 +1,22 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Footer } from "@/components/Footer"; import { Nav } from "@/components/Nav"; import { useLiveIntents } from "@/hooks/useLiveIntents"; -import { computeAnalytics, getStatusDistributionEntries } from "@/lib/analytics"; +import { + computeAnalytics, + getStatusDistributionEntries, + computeShareSeries, + computeConcentration, + computeTopMovers, + type ShareDimension, + type ShareSeriesPoint, + type TopMover, + type ConcentrationResult, +} from "@/lib/analytics"; import { CHAINS } from "@/lib/marketData"; +import { Tooltip } from "@/components/Tooltip"; const formatUsd = (value: number) => new Intl.NumberFormat("en-US", { style: "currency", @@ -106,11 +117,244 @@ function StatusBreakdown({ counts }: { counts: ReturnType; + normalized: boolean; +}) { + const width = 640; + const height = 200; + const padX = 24; + const padY = 16; + + if (seriesData.length === 0 || entities.length === 0) { + return
No share data for this window.
; + } + + // Build stacked paths: for each entity, compute cumulative baseline + const stackedPaths: { entity: string; color: string; path: string }[] = []; + + const n = seriesData.length; + + // For each point, compute the stack + const stackTops = seriesData.map((point) => { + let cumulative = 0; + return entities.map((entity) => { + const value = normalized + ? (point.shares[entity] ?? 0) + : (point.volumes[entity] ?? 0); + cumulative += value; + return cumulative; + }); + }); + + // Max value for scaling + const maxValue = Math.max(...stackTops.map((tops) => tops[tops.length - 1] ?? 0), 1); + + const xCoord = (i: number) => + padX + (i / Math.max(n - 1, 1)) * (width - padX * 2); + + const yCoord = (value: number) => + height - padY - (value / maxValue) * (height - padY * 2); + + entities.forEach((entity, entityIdx) => { + const topLine = seriesData.map((_, i) => { + const top = stackTops[i]?.[entityIdx] ?? 0; + return { x: xCoord(i), y: yCoord(top) }; + }); + + const baseLine = + entityIdx === 0 + ? seriesData.map((_, i) => ({ x: xCoord(i), y: yCoord(0) })) + : seriesData.map((_, i) => { + const base = stackTops[i]?.[entityIdx - 1] ?? 0; + return { x: xCoord(i), y: yCoord(base) }; + }); + + const pathD = [ + `M ${topLine[0]!.x} ${topLine[0]!.y}`, + ...topLine.slice(1).map((p) => `L ${p.x} ${p.y}`), + ...baseLine.slice().reverse().map((p) => `L ${p.x} ${p.y}`), + "Z", + ].join(" "); + + stackedPaths.push({ entity, color: colors[entity] ?? "#4CEBA8", path: pathD }); + }); + + return ( +
+ + {/* Grid lines */} + {[0, 0.25, 0.5, 0.75, 1].map((frac) => { + const y = padY + frac * (height - padY * 2); + return ( + + ); + })} + + {/* Stacked areas (render bottom-to-top) */} + {[...stackedPaths].reverse().map(({ entity, color, path }) => ( + + ))} + + + {/* Legend */} +
+ {entities.map((entity) => ( +
+
+ ))} +
+
+ ); +} + +/** Top movers panel: entities ranked by largest share delta. */ +function TopMoversPanel({ movers }: { movers: TopMover[] }) { + if (movers.length === 0) { + return
Not enough data to calculate movers.
; + } + + return ( +
+ {movers.map((mover) => ( +
+
+
+
+ {mover.currentSharePct}% + 0 + ? "text-vx-sage" + : mover.deltaSharePct < 0 + ? "text-red-400" + : "text-vx-muted" + }`} + aria-label={`${mover.deltaSharePct >= 0 ? "Gained" : "Lost"} ${Math.abs(mover.deltaSharePct)} percentage points`} + > + {mover.deltaSharePct > 0 ? "▲" : mover.deltaSharePct < 0 ? "▼" : "—"}{" "} + {Math.abs(mover.deltaSharePct)}pp + +
+
+ ))} +
+ ); +} + +/** Concentration indicator showing HHI and top-1/top-3 shares. */ +function ConcentrationIndicator({ result }: { result: ConcentrationResult }) { + const levelLabel = { + competitive: "Competitive", + moderate: "Moderately concentrated", + concentrated: "Highly concentrated", + }[result.level]; + + const levelColor = { + competitive: "text-vx-sage", + moderate: "text-yellow-400", + concentrated: "text-red-400", + }[result.level]; + + const hhiExplanation = + "The Herfindahl–Hirschman Index (HHI) measures market concentration. " + + "It is calculated as the sum of squared market-share percentages. " + + "HHI < 1,500 = competitive; 1,500–2,500 = moderate; > 2,500 = concentrated (max 10,000 = monopoly). " + + "High solver concentration is a decentralisation risk signal."; + + return ( +
+
+ {levelLabel} + + + HHI: {result.hhi.toLocaleString()} + + +
+ +
+
+
Top-1 share
+
{result.top1SharePct}%
+
+
+
Top-3 share
+
{result.top3SharePct}%
+
+
+ + {/* Top entities mini-list */} +
+ {result.entities.slice(0, 5).map((e) => ( +
+ {e.label} + {Math.round(e.sharePct * 10) / 10}% +
+ ))} +
+
+ ); +} + export default function AnalyticsPageClient() { const { intents, isLoading, error } = useLiveIntents(); + const [dimension, setDimension] = useState("srcChain"); + const [normalized, setNormalized] = useState(false); const analytics = useMemo(() => computeAnalytics(intents), [intents]); const statusEntries = useMemo(() => getStatusDistributionEntries(analytics.statusCounts), [analytics.statusCounts]); + const shareSeries = useMemo(() => computeShareSeries(intents, dimension), [intents, dimension]); + const concentration = useMemo(() => computeConcentration(intents, "solver"), [intents]); + const topMovers = useMemo(() => computeTopMovers(intents, dimension), [intents, dimension]); if (isLoading && intents.length === 0) { return ( @@ -262,6 +506,72 @@ export default function AnalyticsPageClient() { + + {/* ── Issue #468: Market-share trends ────────────────────────────── */} +
+
+
+
Market share trends
+

Share over time

+
+
+ {/* Dimension selector */} + {(["srcChain", "dstToken", "solver"] as ShareDimension[]).map((dim) => ( + + ))} + {/* Normalise toggle */} + +
+
+ +
+ + {/* ── Issue #468: Top movers + Concentration ──────────────────────── */} +
+
+
Top movers (7d vs previous 7d)
+

Largest share shifts

+
+ +
+
+ +
+
Solver concentration
+

HHI & top-1/top-3 share

+
+ +
+
+