diff --git a/src/App.css b/src/App.css index bf5d1f3..3163385 100644 --- a/src/App.css +++ b/src/App.css @@ -1753,6 +1753,74 @@ color: #52525b; } +/* "Verified on-chain" confidence pill. + * + * Small, low-contrast by design — it complements the cohort chip + * rather than shouting over it. The ✓ mark carries the semantic + * weight; the label is only a timestamp hint. We show it only when + * the reconciler has freshly observed the user's confirmed vote(s), + * so it should read as "this is live info, not cached". */ +.verified-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(20, 184, 166, 0.16); + color: #0a7a55; + font-weight: 600; + letter-spacing: 0.01em; +} + +.verified-chip__mark { + font-weight: 700; + line-height: 1; +} + +.verified-chip__label { + font-size: 0.74rem; + font-variant-numeric: tabular-nums; +} + +/* Metadata chips — proposal-level context surfaces: closing window, + * over-budget rank pressure, and margin warnings. Tone classes keep + * visual language consistent with cohort chips: warm for urgency / + * over-budget (action-requiring), cool for margin signals (neutral + * informational). + * + * We keep type-weight deliberately light so a row with three + * metadata chips + a verified pill + a cohort chip still reads as + * one status column rather than a traffic jam. + */ +.meta-chip { + font-size: 0.75rem; + font-weight: 600; +} + +.meta-chip--closing-urgent { + background: rgba(229, 107, 85, 0.18); + color: #a8362d; +} + +.meta-chip--closing-soon { + background: rgba(243, 179, 86, 0.18); + color: #8a5c0f; +} + +.meta-chip--over-budget { + background: rgba(229, 107, 85, 0.12); + color: #a8362d; + border: 1px dashed rgba(229, 107, 85, 0.35); +} + +.meta-chip--margin-thin { + background: rgba(243, 179, 86, 0.14); + color: #8a5c0f; +} + +.meta-chip--margin-near { + background: rgba(30, 120, 255, 0.1); + color: #1a4fb0; +} + /* Stack the two chips vertically when the row is narrow; side-by-side on wider viewports. proposal-row__status is already a grid column of its own, so we align-content to the top to keep chips compact. */ @@ -2565,3 +2633,420 @@ grid-template-columns: 1fr; } } + +/* ───────────── Governance ops hero (PR 6c) ───────────── + * + * Sits above the proposal table when the viewer is authenticated + * and gives a personal, at-a-glance read of their governance + * workload. Intentionally shares spacing with .panel so the hero + * visually belongs to the same column as the proposal table below. + */ +.ops-hero { + margin-top: 18px; + padding: 22px 26px; + background: + linear-gradient( + 135deg, + rgba(30, 120, 255, 0.08) 0%, + rgba(20, 184, 166, 0.06) 100% + ), + var(--panel-strong); + border: 1px solid var(--accent-soft); +} + +.ops-hero__body { + display: grid; + gap: 16px; +} + +.ops-hero__body h2 { + margin: 0; + font-size: clamp(1.05rem, 1.2vw + 0.9rem, 1.4rem); + line-height: 1.25; + color: var(--text); +} + +.ops-hero__body h2 strong { + color: var(--accent-strong); + font-weight: 600; +} + +.ops-hero__copy { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.ops-hero__progress { + display: grid; + gap: 6px; +} + +.ops-hero__progress-label { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + font-size: 0.9rem; + color: var(--muted); +} + +.ops-hero__progress-percent { + font-variant-numeric: tabular-nums; + color: var(--accent-strong); + font-weight: 600; +} + +.ops-hero__progress-track { + position: relative; + height: 8px; + border-radius: 999px; + background: rgba(44, 74, 117, 0.1); + overflow: hidden; +} + +.ops-hero__progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent) 0%, #58c7ff 100%); + transition: width 280ms ease-out; +} + +.ops-hero__stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.ops-hero__stat { + display: grid; + gap: 4px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); +} + +.ops-hero__stat-label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.ops-hero__stat strong { + font-size: clamp(1.3rem, 1.4vw + 0.8rem, 1.7rem); + line-height: 1.05; + font-variant-numeric: tabular-nums; + color: var(--text); +} + +.ops-hero__stat small { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.35; +} + +.ops-hero__stat--needs-vote strong { + color: var(--accent-strong); +} + +.ops-hero__stat--voted strong { + color: #0a7a55; +} + +.ops-hero__stat--passing strong { + color: #125ac4; +} + +.ops-hero__actions { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.ops-hero__hint { + color: var(--muted); + font-size: 0.9rem; +} + +.ops-hero__hint--ok { + color: #0a7a55; +} + +.ops-hero--empty .ops-hero__body, +.ops-hero--loading .ops-hero__body { + gap: 10px; +} + +.ops-hero__skeleton { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.ops-hero__skeleton span { + height: 72px; + border-radius: 16px; + background: linear-gradient( + 90deg, + rgba(44, 74, 117, 0.06) 0%, + rgba(44, 74, 117, 0.12) 50%, + rgba(44, 74, 117, 0.06) 100% + ); + background-size: 200% 100%; + animation: ops-hero-shimmer 1.4s linear infinite; +} + +@keyframes ops-hero-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +/* Jump-to-next target: briefly glow the row so the eye can land + * on it without the user scanning the whole list. + */ +.proposal-row.is-highlighted { + box-shadow: 0 0 0 3px var(--accent-soft); + background: + linear-gradient( + 90deg, + rgba(30, 120, 255, 0.08) 0%, + rgba(30, 120, 255, 0) 100% + ), + rgba(30, 120, 255, 0.04); + animation: ops-hero-pulse 2.4s ease-out 1; +} + +@keyframes ops-hero-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(30, 120, 255, 0.35); + } + 50% { + box-shadow: 0 0 0 6px rgba(30, 120, 255, 0.18); + } + 100% { + box-shadow: 0 0 0 3px var(--accent-soft); + } +} + +@media (max-width: 780px) { + .ops-hero__stats, + .ops-hero__skeleton { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 460px) { + .ops-hero__stats, + .ops-hero__skeleton { + grid-template-columns: 1fr; + } +} + +/* Auth-only rail stacks the ops hero and activity card side-by-side + * on wide viewports, then wraps below. + */ +.gov-auth-rail { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); + gap: 18px; + margin-top: 18px; +} + +.gov-auth-rail > .ops-hero { + margin-top: 0; +} + +@media (max-width: 960px) { + .gov-auth-rail { + grid-template-columns: 1fr; + } +} + +/* ─────────────── "Your activity" card ─────────────── */ +.gov-activity { + padding: 20px 22px; +} + +.gov-activity__header { + display: grid; + gap: 4px; + margin-bottom: 12px; +} + +.gov-activity__header h3 { + margin: 0; + font-size: 1.05rem; +} + +.gov-activity__hint { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.gov-activity__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} + +.gov-activity__item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 255, 255, 0.65); + align-items: center; +} + +.gov-activity__item-main { + display: grid; + gap: 2px; + min-width: 0; +} + +.gov-activity__item-title { + font-size: 0.95rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gov-activity__jump { + appearance: none; + background: transparent; + border: 0; + padding: 0; + margin: 0; + font: inherit; + color: var(--accent-strong); + cursor: pointer; + text-align: left; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gov-activity__jump:hover, +.gov-activity__jump:focus-visible { + text-decoration: underline; + outline: none; +} + +.gov-activity__title-inert { + color: var(--text); +} + +.gov-activity__item-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; + color: var(--muted); + font-size: 0.82rem; +} + +.gov-activity__warn { + color: #a8362d; +} + +.gov-activity__item-side { + display: grid; + gap: 2px; + justify-items: end; + text-align: right; +} + +.gov-activity__status { + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.04em; + padding: 3px 8px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-strong); + white-space: nowrap; +} + +.gov-activity__status--confirmed { + background: rgba(10, 122, 85, 0.12); + color: #0a7a55; +} + +.gov-activity__status--relayed { + background: rgba(30, 120, 255, 0.12); + color: var(--accent-strong); +} + +.gov-activity__status--stale, +.gov-activity__status--failed { + background: rgba(229, 107, 85, 0.14); + color: #a8362d; +} + +.gov-activity__time { + color: var(--muted); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +/* ─────────── Pre-submit support-shift preview ─────────── + * + * Renders inside the vote modal's picker phase, below the outcome + * selector. Tone classes mirror the delta direction so a yes-heavy + * selection reads positive (mint), a no-heavy selection reads + * negative (warm), and abstain / no-op selections read neutral. + */ +.vote-modal__shift { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + align-items: baseline; + padding: 10px 12px; + border-radius: 12px; + background: var(--accent-soft); + border: 1px solid rgba(30, 120, 255, 0.18); + font-size: 0.9rem; +} + +.vote-modal__shift-headline { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.vote-modal__shift-detail { + color: var(--muted); +} + +.vote-modal__shift--positive { + background: rgba(20, 184, 166, 0.1); + border-color: rgba(20, 184, 166, 0.3); +} + +.vote-modal__shift--positive .vote-modal__shift-headline { + color: #0a7a55; +} + +.vote-modal__shift--negative { + background: rgba(229, 107, 85, 0.1); + border-color: rgba(229, 107, 85, 0.3); +} + +.vote-modal__shift--negative .vote-modal__shift-headline { + color: #a8362d; +} + +.vote-modal__shift--neutral .vote-modal__shift-headline { + color: var(--muted); +} diff --git a/src/components/GovernanceActivity.js b/src/components/GovernanceActivity.js new file mode 100644 index 0000000..19d9daa --- /dev/null +++ b/src/components/GovernanceActivity.js @@ -0,0 +1,332 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { governanceService as defaultService } from '../lib/governanceService'; + +// Small helper: "relative time" for a millisecond timestamp. +// Intentionally local to this component — we don't ship a relative- +// time formatter in `lib/formatters` yet, and the rules here are +// tuned for the activity card specifically (seconds→weeks; beyond +// that, surface an absolute UTC date). If another call-site needs +// this, it should move to `lib/formatters`. +function formatRelativeMs(ms, nowMs) { + if (!Number.isFinite(ms) || ms <= 0) return ''; + const now = Number.isFinite(nowMs) ? nowMs : Date.now(); + const diffSec = Math.round((now - ms) / 1000); + const abs = Math.abs(diffSec); + const future = diffSec < 0; + if (abs < 5) return 'just now'; + if (abs < 60) return future ? `in ${abs}s` : `${abs}s ago`; + const mins = Math.round(abs / 60); + if (mins < 60) return future ? `in ${mins}m` : `${mins}m ago`; + const hrs = Math.round(abs / 3600); + if (hrs < 24) return future ? `in ${hrs}h` : `${hrs}h ago`; + const days = Math.round(abs / 86400); + if (days < 7) return future ? `in ${days}d` : `${days}d ago`; + const weeks = Math.round(days / 7); + if (weeks < 5) return future ? `in ${weeks}w` : `${weeks}w ago`; + // Beyond ~5 weeks, a locale-aware absolute date reads better than + // "12w ago". Use UTC to avoid timezone drift between server + // submitted_at and the user's local clock. + try { + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(new Date(ms)); + } catch (_e) { + return ''; + } +} + +function formatAbsoluteUtc(ms) { + if (!Number.isFinite(ms) || ms <= 0) return ''; + try { + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + timeZone: 'UTC', + }).format(new Date(ms)); + } catch (_e) { + return ''; + } +} + +// "Your activity" card — the N most-recent vote receipts across +// every proposal the user has acted on, with a deep-link on each +// row that jumps the proposal table back to the originating row. +// +// Why its own component (not a section of the ops hero): +// +// * It only makes sense when the user has *already* voted on +// something; on fresh accounts the hero gives a better read. +// * The list can grow to 10 rows and contains its own interactive +// elements (jump buttons). Inlining it into the hero would blur +// the visual boundary between "summary" and "history". +// * Loading is independent of the hero — we can show stale +// receipts while the summary refreshes, or vice versa. +// +// Data flow: +// +// This component owns its own fetch against +// `governanceService.fetchRecentReceipts`. The caller passes in +// a `proposalsByHash` map (hash→proposal feed row) so we can +// render a title and a jump-link without round-tripping the +// feed. If a receipt points at a proposal that's no longer in +// the feed (archived, re-org, operator purge), we render the +// row with the hash prefix only and an inert "not in feed" +// label — the receipt itself stays informative. + +const OUTCOME_LABEL = { + yes: 'Voted yes', + no: 'Voted no', + abstain: 'Abstained', + none: 'Vote removed', +}; + +const STATUS_LABEL = { + confirmed: 'On-chain', + relayed: 'Submitted', + stale: 'Needs retry', + failed: 'Failed', +}; + +const STATUS_CLASS = { + confirmed: 'gov-activity__status gov-activity__status--confirmed', + relayed: 'gov-activity__status gov-activity__status--relayed', + stale: 'gov-activity__status gov-activity__status--stale', + failed: 'gov-activity__status gov-activity__status--failed', +}; + +function shortHash(h) { + if (typeof h !== 'string' || h.length < 10) return h || ''; + return `${h.slice(0, 6)}…${h.slice(-4)}`; +} + +function lastSeenMs(receipt) { + // `verified_at` wins when present — it's the last time we + // observed the row on-chain and is the more truthful signal of + // "when was this actually current". `submitted_at` is the + // fallback for rows that have never reconciled. + const v = Number(receipt && receipt.verifiedAt); + if (Number.isFinite(v) && v > 0) return v; + const s = Number(receipt && receipt.submittedAt); + if (Number.isFinite(s) && s > 0) return s; + return null; +} + +function outcomeLabel(outcome) { + return OUTCOME_LABEL[outcome] || 'Vote'; +} + +function statusLabel(status) { + return STATUS_LABEL[status] || status || ''; +} + +function statusClass(status) { + return STATUS_CLASS[status] || 'gov-activity__status'; +} + +export default function GovernanceActivity({ + proposalsByHash, + governanceService = defaultService, + limit = 10, + refreshToken = 0, + onJumpToProposal, +}) { + const [state, setState] = useState({ + loading: true, + error: null, + receipts: [], + }); + const mountedRef = useRef(true); + const genRef = useRef(0); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const load = useCallback(async () => { + const myGen = ++genRef.current; + setState((prev) => ({ ...prev, loading: true, error: null })); + try { + const out = await governanceService.fetchRecentReceipts({ limit }); + if (!mountedRef.current || genRef.current !== myGen) return; + setState({ + loading: false, + error: null, + receipts: Array.isArray(out.receipts) ? out.receipts : [], + }); + } catch (err) { + if (!mountedRef.current || genRef.current !== myGen) return; + setState({ + loading: false, + error: (err && err.code) || 'activity_failed', + receipts: [], + }); + } + }, [governanceService, limit]); + + useEffect(() => { + load(); + }, [load, refreshToken]); + + const proposalLookup = useMemo(() => { + if (proposalsByHash instanceof Map) return proposalsByHash; + const m = new Map(); + if (proposalsByHash && typeof proposalsByHash === 'object') { + for (const [k, v] of Object.entries(proposalsByHash)) { + if (typeof k === 'string') m.set(k.toLowerCase(), v); + } + } + return m; + }, [proposalsByHash]); + + const { loading, error, receipts } = state; + + if (loading && receipts.length === 0) { + return ( + + ); + } + + if (error && receipts.length === 0) { + return ( + + ); + } + + if (receipts.length === 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/GovernanceActivity.test.js b/src/components/GovernanceActivity.test.js new file mode 100644 index 0000000..33d976c --- /dev/null +++ b/src/components/GovernanceActivity.test.js @@ -0,0 +1,208 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; + +import GovernanceActivity from './GovernanceActivity'; + +function makeService(overrides = {}) { + return { + fetchRecentReceipts: jest.fn().mockResolvedValue({ receipts: [] }), + ...overrides, + }; +} + +function baseReceipt(o) { + return { + id: 1, + proposalHash: 'a'.repeat(64), + collateralHash: 'b'.repeat(64), + collateralIndex: 0, + voteOutcome: 'yes', + voteSignal: 'funding', + voteTime: 1_700_000_000, + status: 'confirmed', + lastError: null, + submittedAt: Date.now() - 60_000, + verifiedAt: Date.now() - 30_000, + ...o, + }; +} + +describe('GovernanceActivity', () => { + test('renders loading state initially', () => { + const svc = makeService({ + fetchRecentReceipts: () => new Promise(() => {}), // never resolves + }); + render(); + expect(screen.getByTestId('gov-activity-loading')).toBeInTheDocument(); + }); + + test('renders empty state when the user has no receipts', async () => { + const svc = makeService(); + render(); + await waitFor(() => { + expect(screen.getByTestId('gov-activity-empty')).toBeInTheDocument(); + }); + }); + + test('renders receipt list with jump buttons when proposal is in feed', async () => { + const hash = 'a'.repeat(64); + const svc = makeService({ + fetchRecentReceipts: jest.fn().mockResolvedValue({ + receipts: [baseReceipt({ proposalHash: hash })], + }), + }); + const proposalsByHash = new Map([ + [hash, { Key: hash, title: 'Fund the node infra' }], + ]); + const onJumpToProposal = jest.fn(); + render( + + ); + await waitFor(() => { + expect(screen.getByTestId('gov-activity')).toBeInTheDocument(); + }); + const jump = screen.getByTestId('gov-activity-jump'); + expect(jump).toHaveTextContent('Fund the node infra'); + fireEvent.click(jump); + expect(onJumpToProposal).toHaveBeenCalledWith(hash); + }); + + test('renders a short-hash and "not in feed" label when proposal is missing', async () => { + const hash = 'a'.repeat(64); + const svc = makeService({ + fetchRecentReceipts: jest.fn().mockResolvedValue({ + receipts: [baseReceipt({ proposalHash: hash })], + }), + }); + render( + + ); + await waitFor(() => { + expect(screen.getByTestId('gov-activity')).toBeInTheDocument(); + }); + // No jump button — nothing to jump to. + expect(screen.queryByTestId('gov-activity-jump')).not.toBeInTheDocument(); + expect(screen.getByText(/not in current feed/i)).toBeInTheDocument(); + }); + + test('passes limit to the service', async () => { + const svc = makeService(); + render( + + ); + await waitFor(() => { + expect(svc.fetchRecentReceipts).toHaveBeenCalledWith({ limit: 5 }); + }); + }); + + test('refreshes when the refreshToken prop changes', async () => { + const svc = makeService(); + const { rerender } = render( + + ); + await waitFor(() => { + expect(svc.fetchRecentReceipts).toHaveBeenCalledTimes(1); + }); + rerender( + + ); + await waitFor(() => { + expect(svc.fetchRecentReceipts).toHaveBeenCalledTimes(2); + }); + }); + + test('shows an error state with a retry button on failure', async () => { + const fail = Object.assign(new Error('network_error'), { + code: 'network_error', + }); + const svc = makeService({ + fetchRecentReceipts: jest + .fn() + .mockRejectedValueOnce(fail) + .mockResolvedValueOnce({ receipts: [] }), + }); + render(); + await waitFor(() => { + expect(screen.getByTestId('gov-activity-error')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('gov-activity-retry')); + await waitFor(() => { + expect(screen.getByTestId('gov-activity-empty')).toBeInTheDocument(); + }); + }); + + test('renders outcome + status labels for each receipt status', async () => { + const hashA = 'a'.repeat(64); + const hashB = 'b'.repeat(64); + const hashC = 'c'.repeat(64); + const svc = makeService({ + fetchRecentReceipts: jest.fn().mockResolvedValue({ + receipts: [ + baseReceipt({ + id: 1, + proposalHash: hashA, + status: 'confirmed', + voteOutcome: 'yes', + }), + baseReceipt({ + id: 2, + proposalHash: hashB, + status: 'relayed', + voteOutcome: 'no', + }), + baseReceipt({ + id: 3, + proposalHash: hashC, + status: 'failed', + voteOutcome: 'abstain', + lastError: 'signature_invalid', + }), + ], + }), + }); + render( + + ); + await waitFor(() => { + expect(screen.getAllByTestId('gov-activity-item')).toHaveLength(3); + }); + expect(screen.getByText('On-chain')).toBeInTheDocument(); + expect(screen.getByText('Submitted')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByText('Voted yes')).toBeInTheDocument(); + expect(screen.getByText('Voted no')).toBeInTheDocument(); + expect(screen.getByText('Abstained')).toBeInTheDocument(); + }); +}); diff --git a/src/components/GovernanceOpsHero.js b/src/components/GovernanceOpsHero.js new file mode 100644 index 0000000..97aecd8 --- /dev/null +++ b/src/components/GovernanceOpsHero.js @@ -0,0 +1,284 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; + +import { computeOpsStats } from '../lib/governanceOps'; +import { formatNumber } from '../lib/formatters'; + +// Ops-summary hero that sits above the proposal table for +// authenticated users. The goal is to give a voting operator a +// one-glance read of: +// +// * "Am I set up?" (represent chip: N masternodes) +// * "What do I owe the net?" (needs-vote count, with CTA) +// * "How close am I to done?" (progress bar with %) +// * "What does the room look like?" (passing / watching) +// +// We deliberately keep the hero copy *plural-aware* rather than +// branching big chunks of JSX — the information density matches +// what's already on the page above, but the framing is personal. +// +// Mount gating: +// +// The caller (Governance page) only renders this when the user is +// authenticated; that way the hook calls that feed it (owned MN +// lookup, receipt summary) have already fired by the time we +// render. The component itself is defensive — if `ownedCount` is +// null (vault still locked, lookup still loading), the stats +// helper reports everything as "not-applicable" and we render a +// gentle skeleton state instead of stale numbers. + +function pluralMn(n) { + return n === 1 ? 'masternode' : 'masternodes'; +} + +function pluralProposal(n) { + return n === 1 ? 'proposal' : 'proposals'; +} + +function formatCount(n) { + if (!Number.isFinite(n)) return '—'; + return formatNumber(n); +} + +export default function GovernanceOpsHero({ + proposals, + summaryMap, + ownedCount, + enabledCount, + onJumpToProposal, +}) { + const stats = useMemo( + () => + computeOpsStats({ + proposals, + summaryMap, + ownedCount, + enabledCount, + }), + [proposals, summaryMap, ownedCount, enabledCount] + ); + + const isVaultEmpty = ownedCount === 0; + const isAwaitingLookup = ownedCount === null; + const hasApplicable = stats.applicable > 0; + + // 1) User has no voting keys imported yet — gently nudge them to + // the account page instead of showing zero-filled chips. + if (isVaultEmpty) { + return ( + + ); + } + + // 2) Vault is unlocked but the owned-MN lookup hasn't landed yet. + // Render a skeleton rather than flash zeros. + if (isAwaitingLookup) { + return ( + + ); + } + + const { needsVote, voted, passing, watching, progressPercent } = stats; + const allDone = hasApplicable && needsVote === 0; + + // Primary headline — one sentence, personal, actionable. + let headline; + if (!hasApplicable) { + // Owns MNs but none of the displayed proposals apply (e.g. + // filtered list is empty). Unusual but graceful. + headline = ( + <> + You represent {formatCount(ownedCount)}{' '} + {pluralMn(ownedCount)}. No proposals match the current view. + + ); + } else if (allDone) { + headline = ( + <> + All caught up — {formatCount(voted)}{' '} + {pluralProposal(voted)} voted with your{' '} + {formatCount(ownedCount)} {pluralMn(ownedCount)}. + + ); + } else { + headline = ( + <> + {formatCount(needsVote)}{' '} + {pluralProposal(needsVote)} need your vote across your{' '} + {formatCount(ownedCount)} {pluralMn(ownedCount)}. + + ); + } + + function handleJump() { + if (!stats.nextUnvotedKey) return; + if (typeof onJumpToProposal === 'function') { + onJumpToProposal(stats.nextUnvotedKey); + } + } + + return ( + + ); +} diff --git a/src/components/GovernanceOpsHero.test.js b/src/components/GovernanceOpsHero.test.js new file mode 100644 index 0000000..b7274da --- /dev/null +++ b/src/components/GovernanceOpsHero.test.js @@ -0,0 +1,179 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import GovernanceOpsHero from './GovernanceOpsHero'; + +function mkProposal({ key, yes = 0 }) { + return { Key: key, AbsoluteYesCount: yes }; +} + +function baseRow(o) { + return { + proposalHash: '', + total: 0, + relayed: 0, + confirmed: 0, + stale: 0, + failed: 0, + confirmedYes: 0, + confirmedNo: 0, + confirmedAbstain: 0, + latestSubmittedAt: null, + latestVerifiedAt: null, + ...o, + }; +} + +function mapFromRows(rows) { + const m = new Map(); + for (const r of rows) m.set(r.proposalHash.toLowerCase(), r); + return m; +} + +function renderHero(props) { + return render( + + + + ); +} + +describe('GovernanceOpsHero', () => { + const p1 = mkProposal({ key: 'a'.repeat(64), yes: 200 }); + const p2 = mkProposal({ key: 'b'.repeat(64), yes: 50 }); + const p3 = mkProposal({ key: 'c'.repeat(64), yes: 300 }); + + test('renders the empty-state CTA when the vault has no voting keys', () => { + renderHero({ + proposals: [p1, p2], + summaryMap: new Map(), + ownedCount: 0, + enabledCount: 1000, + }); + expect(screen.getByTestId('gov-ops-hero-empty')).toBeInTheDocument(); + const link = screen.getByTestId('gov-ops-hero-account-link'); + expect(link).toHaveAttribute('href', '/account'); + }); + + test('renders a skeleton while the owned-MN lookup is loading', () => { + renderHero({ + proposals: [p1, p2], + summaryMap: new Map(), + ownedCount: null, + enabledCount: 1000, + }); + expect(screen.getByTestId('gov-ops-hero-loading')).toBeInTheDocument(); + }); + + test('summarises counts and exposes a jump-to-next button', () => { + const map = mapFromRows([ + baseRow({ + proposalHash: p1.Key, + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + // p2 missing from summary → needs-vote. + baseRow({ + proposalHash: p3.Key, + total: 2, + confirmed: 1, + confirmedYes: 1, + failed: 1, + }), + ]); + const onJumpToProposal = jest.fn(); + renderHero({ + proposals: [p1, p2, p3], + summaryMap: map, + ownedCount: 2, + enabledCount: 1000, + onJumpToProposal, + }); + // Primary headline reflects the needs-vote count. + const headline = screen.getByTestId('gov-ops-hero-headline'); + expect(headline.textContent).toMatch(/2\s+proposals?\s+need your vote/i); + // Stats cards — read the `` number explicitly rather + // than relying on word-boundary regexes, which don't treat + // "vote2" as a boundary because both sides are word chars. + expect( + screen.getByTestId('gov-ops-hero-needs-vote').querySelector('strong') + .textContent + ).toBe('2'); + expect( + screen.getByTestId('gov-ops-hero-voted').querySelector('strong') + .textContent + ).toBe('1'); + expect( + screen.getByTestId('gov-ops-hero-passing').querySelector('strong') + .textContent + ).toBe('2'); + // Jump link goes to the first display-order proposal needing a vote (p2). + fireEvent.click(screen.getByTestId('gov-ops-hero-jump')); + expect(onJumpToProposal).toHaveBeenCalledWith(p2.Key); + }); + + test('progress bar renders with the right value', () => { + const map = mapFromRows([ + baseRow({ + proposalHash: p1.Key, + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + ]); + renderHero({ + proposals: [p1, p2, p3], + summaryMap: map, + ownedCount: 2, + enabledCount: 1000, + }); + const progress = screen.getByTestId('gov-ops-hero-progress'); + // 1 voted / 3 applicable = 33% + expect(progress.textContent).toMatch(/Voted\s+1\s+of\s+3/); + expect(progress.textContent).toMatch(/33%/); + const bar = progress.querySelector('[role="progressbar"]'); + expect(bar).toHaveAttribute('aria-valuenow', '33'); + }); + + test('shows a celebratory "all done" state when nothing is pending', () => { + const map = mapFromRows([ + baseRow({ + proposalHash: p1.Key, + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + baseRow({ + proposalHash: p2.Key, + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + ]); + renderHero({ + proposals: [p1, p2], + summaryMap: map, + ownedCount: 2, + enabledCount: 1000, + }); + expect(screen.queryByTestId('gov-ops-hero-jump')).not.toBeInTheDocument(); + expect(screen.getByTestId('gov-ops-hero-done')).toBeInTheDocument(); + const hero = screen.getByTestId('gov-ops-hero'); + expect(hero).toHaveAttribute('data-all-done', 'true'); + }); + + test('jump button is suppressed when there is nothing to jump to', () => { + // Zero proposals visible — "applicable" is 0 and we have no + // next key. The jump button must not render or it would send + // the user nowhere. + renderHero({ + proposals: [], + summaryMap: new Map(), + ownedCount: 2, + enabledCount: 1000, + }); + expect(screen.queryByTestId('gov-ops-hero-jump')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ProposalVoteModal.js b/src/components/ProposalVoteModal.js index 4807eed..6255e06 100644 --- a/src/components/ProposalVoteModal.js +++ b/src/components/ProposalVoteModal.js @@ -17,6 +17,10 @@ import { isBenignDup, SEVERITY, } from '../lib/governanceErrors'; +import { + computeSupportShift, + describeSupportShift, +} from '../lib/governanceSupportShift'; import { enqueue as enqueueOfflineVote, drain as drainOfflineVote, @@ -431,6 +435,25 @@ export default function ProposalVoteModal({ return computeDefault(owned, outcome); }, [selected, owned, outcome, computeDefault]); + // Pre-submit preview of how the current selection would move the + // proposal's net on-chain support. Drives an informational banner + // in the picker so a vote change never happens silently — e.g. a + // user who previously voted yes on 3 MNs and now has "no" + // selected for all 3 sees "Net support −6 · 3 prior confirmed + // votes will change" before clicking Sign & submit. + const supportShift = useMemo(() => { + if (effectiveSelected.size === 0) return null; + const entries = owned + .filter((m) => effectiveSelected.has(mnId(m))) + .map((m) => ({ + currentOutcome: outcome, + previousOutcome: m.receipt ? m.receipt.voteOutcome : null, + previousStatus: m.receipt ? m.receipt.status : '', + })); + const shift = computeSupportShift(entries); + return describeSupportShift(shift, entries.length); + }, [owned, effectiveSelected, outcome]); + // Reset local state whenever the modal opens or the proposal // changes. `proposal.Key` is the governance hash and is unique // per proposal, so keying on it prevents stale selection / results @@ -1817,6 +1840,24 @@ export default function ProposalVoteModal({

) : null} + {supportShift ? ( +
+ + {supportShift.headline} + + {supportShift.detail ? ( + + {supportShift.detail} + + ) : null} +
+ ) : null} + {(() => { // Grouped picker render. The outer wrapper retains // `data-testid="vote-modal-list"` so legacy queries that diff --git a/src/components/ProposalVoteModal.test.js b/src/components/ProposalVoteModal.test.js index 534c564..7086641 100644 --- a/src/components/ProposalVoteModal.test.js +++ b/src/components/ProposalVoteModal.test.js @@ -2609,3 +2609,113 @@ describe('ProposalVoteModal — grouped picker + vote-change confirmation', () = expect(service.submitVote).toHaveBeenCalledTimes(1); }); }); + +describe('ProposalVoteModal — pre-submit support-shift preview', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + function makeReceiptService({ receipts = [], reconcileError = null } = {}) { + const s = makeService(); + s.reconcileReceipts = jest.fn().mockResolvedValue({ + receipts, + reconciled: !reconcileError, + reconcileError, + updated: 0, + }); + return s; + } + + function confirmed({ hash, index, outcome = 'yes' }) { + return { + collateralHash: hash, + collateralIndex: index, + status: 'confirmed', + voteOutcome: outcome, + voteSignal: 'funding', + voteTime: 1700000000, + verifiedAt: 1700000001, + updatedAt: 1700000001, + createdAt: 1700000000, + lastError: null, + }; + } + + test('fresh yes selection on two MNs previews a +2 positive shift', async () => { + const service = makeReceiptService({ receipts: [] }); + renderModal({ vault: UNLOCKED_VAULT_WITH_TWO_KEYS, service }); + + await waitFor(() => { + expect(screen.getByTestId('vote-modal-list')).toBeInTheDocument(); + }); + + const shift = screen.getByTestId('vote-modal-shift'); + expect(shift.getAttribute('data-shift-tone')).toBe('positive'); + expect(shift.getAttribute('data-shift-delta')).toBe('2'); + expect(shift.textContent).toMatch(/\+2/); + }); + + test('flipping outcome to no with a prior confirmed yes previews a −2 negative shift and flags replacement', async () => { + // MN A has a confirmed "yes" receipt. User flips outcome to "no". + // Default selection includes A (vote-change candidate) and B. + // Net on-chain move: A flips (−2) + B fresh no (−1) = −3, with + // one confirmed replacement called out in the detail line. + const service = makeReceiptService({ + receipts: [confirmed({ hash: 'c'.repeat(64), index: 0, outcome: 'yes' })], + }); + renderModal({ vault: UNLOCKED_VAULT_WITH_TWO_KEYS, service }); + + await waitFor(() => { + expect(screen.getByTestId('vote-modal-list')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('vote-modal-outcome-no')); + + await waitFor(() => { + const shift = screen.getByTestId('vote-modal-shift'); + expect(shift.getAttribute('data-shift-tone')).toBe('negative'); + expect(shift.getAttribute('data-shift-delta')).toBe('-3'); + }); + const shift = screen.getByTestId('vote-modal-shift'); + expect(shift.textContent).toMatch(/−3|-3/); + expect(shift.textContent).toMatch(/1 prior confirmed vote/i); + }); + + test('abstain-only selection previews a neutral (zero) shift', async () => { + const service = makeReceiptService({ receipts: [] }); + renderModal({ vault: UNLOCKED_VAULT_WITH_TWO_KEYS, service }); + + await waitFor(() => { + expect(screen.getByTestId('vote-modal-list')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('vote-modal-outcome-abstain')); + + await waitFor(() => { + const shift = screen.getByTestId('vote-modal-shift'); + expect(shift.getAttribute('data-shift-tone')).toBe('neutral'); + expect(shift.getAttribute('data-shift-delta')).toBe('0'); + }); + }); + + test('no selection → no preview is rendered', async () => { + // Both MNs already confirmed yes → default excludes both → empty + // selection → the preview card is hidden so we don't render a + // confusing "+0" banner when there's literally nothing to preview. + const service = makeReceiptService({ + receipts: [ + confirmed({ hash: 'c'.repeat(64), index: 0, outcome: 'yes' }), + confirmed({ hash: 'd'.repeat(64), index: 1, outcome: 'yes' }), + ], + }); + renderModal({ vault: UNLOCKED_VAULT_WITH_TWO_KEYS, service }); + + await waitFor(() => { + expect(screen.getByTestId('vote-modal-list')).toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('vote-modal-shift') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/lib/governanceMeta.js b/src/lib/governanceMeta.js new file mode 100644 index 0000000..f24f9f3 --- /dev/null +++ b/src/lib/governanceMeta.js @@ -0,0 +1,215 @@ +// governanceMeta — pure helpers that derive per-proposal metadata +// chips shown on the Governance feed. +// +// Why these three chips, and why this module: +// +// 1. "Closes soon" — the single biggest reason a vote gets missed +// is that users don't notice the superblock voting deadline is +// imminent. Surfacing an at-a-glance chip on rows that would +// still be relevant for the current superblock prevents the +// "wait, I had plans to vote on that" regret. +// +// 2. "Over budget" — the ~72k SYS (roughly) per-superblock budget +// ceiling is a hard gate: if the sum of currently-passing +// proposals exceeds the ceiling, the lowest-support passing +// proposals get pruned from the final payee list. Users voting +// late need to know whether this proposal is safely funded or +// is the one teetering on the prune line. +// +// 3. "Close vote" — proposals that sit within a narrow band of +// the 10% approval threshold are meaningfully decidable by a +// single voter's action. Flagging them invites engagement +// from users whose votes actually move the needle, which is +// the opposite of the noise-floor chip ("wouldn't matter if +// you voted or not"). +// +// All helpers are pure: tests can drive them without touching +// hooks, fetch, or the DOM, and render-time computation stays +// trivial (Math + clock reads, no iteration over feeds). +// +// Units: epoch timestamps are SECONDS (matching what Core emits +// via governance RPCs). Relative-time outputs are in seconds +// unless otherwise noted. `nowMs` arguments are explicit milliseconds +// so injectable clocks in tests remain unambiguous. + +// The Syscoin governance threshold: a proposal "passes" when +// AbsoluteYesCount / enabledMNs > 10%. Matches the same constant +// used in ProposalRow for the Passing / Not-enough chip. +export const PASSING_SUPPORT_PERCENT = 10; + +// A proposal within ±MARGIN_WARNING_PERCENT of the pass line is +// flagged as a close vote. We chose 1.5 deliberately: too wide and +// every proposal on the feed lights up; too narrow and we miss +// genuinely-contestable rows where rounding on the backend or a +// handful of late voters would flip the outcome. 1.5% = roughly +// ceil(0.015 * enabledCount) votes — a plausible weekend swing +// for the current ~2k–3k enabled masternode cohort. +export const MARGIN_WARNING_PERCENT = 1.5; + +// "Closes soon" urgency tiers. We picked 48h because that's the +// window below which sleeping through a weekend becomes a serious +// risk of missing the vote entirely — a reasonable "pay attention +// now" threshold for casual users. 7d is the secondary "closes +// this week" tier for users who want to plan their voting session +// but aren't at red-alert urgency yet. +export const CLOSING_URGENT_SECONDS = 2 * 24 * 60 * 60; +export const CLOSING_SOON_SECONDS = 7 * 24 * 60 * 60; + +function toSeconds(value) { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +// Format a positive number of seconds as a human phrase like +// "3h", "2d", "1m". Kept purposely terse because chip real estate +// is scarce; the full tooltip copy does the polite phrasing. +function formatCountdown(seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) return '0m'; + if (seconds < 60) return `${Math.max(1, Math.round(seconds))}s`; + if (seconds < 60 * 60) return `${Math.round(seconds / 60)}m`; + if (seconds < 24 * 60 * 60) return `${Math.round(seconds / 3600)}h`; + return `${Math.round(seconds / 86400)}d`; +} + +// closingChip — urgency chip derived from the superblock voting +// deadline. Returns null when: +// * the voting deadline isn't known yet (superblock stats still +// loading); +// * the deadline has already passed — in that case the proposal +// either made it into the payee list or it didn't, and a +// "closed" chip would add noise rather than agency; +// * the window is wider than CLOSING_SOON_SECONDS — the row +// doesn't need an urgency label. +// +// We deliberately key off the SUPERBLOCK voting deadline, not the +// proposal's own end_epoch: Core governance only lets you vote in +// the window that ends at the next deadline, even if the proposal +// itself persists across multiple superblocks. Showing the +// superblock clock is what tells the user "your vote must be cast +// before this moment or it doesn't count for this period". +export function closingChip({ votingDeadline, nowMs } = {}) { + const deadlineSec = toSeconds(votingDeadline); + if (deadlineSec <= 0) return null; + const now = Number.isFinite(nowMs) ? nowMs : Date.now(); + const remainingSec = deadlineSec - Math.floor(now / 1000); + if (remainingSec <= 0) return null; + if (remainingSec > CLOSING_SOON_SECONDS) return null; + const urgent = remainingSec <= CLOSING_URGENT_SECONDS; + return { + kind: urgent ? 'closing-urgent' : 'closing-soon', + label: `Closes in ${formatCountdown(remainingSec)}`, + detail: urgent + ? 'Superblock voting ends soon — cast your vote or it won\u2019t count for this cycle.' + : 'Voting for the next superblock closes within a week.', + remainingSeconds: remainingSec, + }; +} + +// overBudgetChip — warns the user that this proposal is part of a +// set whose collective monthly payouts exceed the superblock +// budget ceiling. When that happens Core prunes the lowest-ranked +// passing proposals until the remaining set fits; rows near the +// pruning cutline should read as "vote pressure matters". +// +// Algorithm: +// 1. Consider only currently-passing proposals (support > 10%). +// 2. Sort them by AbsoluteYesCount descending — Core ranks +// identically when building the final payee list. +// 3. Walk the sorted list, summing payment_amount. The first +// proposal whose cumulative sum exceeds the budget marks the +// cutline: that row (and every row ranked below it) gets the +// over-budget chip. +// +// Returns a Map keyed by lowercase proposal hash → chip descriptor. +// Callers build the map once per render and look rows up by hash. +// A Map (rather than per-row recompute) keeps the row render +// uncoupled from the feed-wide sort. +export function computeOverBudgetMap({ + proposals, + enabledCount, + budget, +}) { + const out = new Map(); + if (!Array.isArray(proposals) || proposals.length === 0) return out; + const ceiling = Number(budget); + if (!(Number.isFinite(ceiling) && ceiling > 0)) return out; + const enabled = Number(enabledCount); + if (!(Number.isFinite(enabled) && enabled > 0)) return out; + + // Snapshot of the passing cohort with whatever rank-relevant + // fields we have. We compute support once here, since the same + // derivation runs in ProposalRow already — no data divergence + // risk as long as PASSING_SUPPORT_PERCENT and the denominator + // stay in sync. + const passing = []; + for (const p of proposals) { + if (!p || typeof p.Key !== 'string') continue; + const support = + (Number(p.AbsoluteYesCount || 0) / enabled) * 100; + if (support <= PASSING_SUPPORT_PERCENT) continue; + passing.push({ + key: p.Key.toLowerCase(), + amount: Number(p.payment_amount || 0), + yes: Number(p.AbsoluteYesCount || 0), + }); + } + passing.sort((a, b) => b.yes - a.yes); + + let running = 0; + for (const row of passing) { + // Coerce non-finite amounts (NaN / Infinity — e.g. a malformed + // payment_amount string from the feed) to 0 before adding. + // Without this, a single NaN contaminates `running` and every + // subsequent `running > ceiling` comparison returns false, + // silently dropping chips on rows that genuinely are past the + // cutline. Clamping at 0 preserves cutline detection for the + // rest of the list and biases toward NOT warning (vs. warning + // spuriously on an unknowable amount). + const amount = Number.isFinite(row.amount) ? Math.max(0, row.amount) : 0; + running += amount; + if (running > ceiling) { + out.set(row.key, { + kind: 'over-budget', + label: 'Over budget', + detail: + 'Currently passing proposals exceed the superblock budget. Low-support rows like this one may be pruned at payout time.', + }); + } + } + return out; +} + +// marginChip — flag proposals whose support sits within a narrow +// band of the 10% pass line. Returns null outside the band, or +// when enabledCount is unknown (no stable denominator). +// +// Tone is split by direction: +// * above the line → "margin-thin": currently passing, at risk +// of dropping out with a few no votes / a few dropped MNs. +// * below the line → "margin-near": currently failing, a handful +// of late yes votes would push it over. +// +// Both carry the same semantic weight; the class split is purely +// so the UI can use color to hint direction of pressure. +export function marginChip({ proposal, enabledCount } = {}) { + if (!proposal) return null; + const enabled = Number(enabledCount); + if (!(Number.isFinite(enabled) && enabled > 0)) return null; + const support = + (Number(proposal.AbsoluteYesCount || 0) / enabled) * 100; + const delta = support - PASSING_SUPPORT_PERCENT; + if (Math.abs(delta) > MARGIN_WARNING_PERCENT) return null; + // Strict > 0 so that support = exactly 10% reads as "close to + // passing", not "slim margin". ProposalRow's pass logic is + // `support > 10`, so a row at exactly 10% is *not* passing and + // the chip copy must match that reality — otherwise a user sees + // "not enough votes" paired with "just above the pass threshold". + const above = delta > 0; + return { + kind: above ? 'margin-thin' : 'margin-near', + label: above ? 'Slim margin' : 'Close to passing', + detail: above + ? 'Support is just above the 10% pass threshold — a handful of No votes could flip this row.' + : 'Support is just below the 10% pass threshold — a handful of Yes votes could push this row over.', + }; +} diff --git a/src/lib/governanceMeta.test.js b/src/lib/governanceMeta.test.js new file mode 100644 index 0000000..81a71ce --- /dev/null +++ b/src/lib/governanceMeta.test.js @@ -0,0 +1,264 @@ +import { + CLOSING_SOON_SECONDS, + CLOSING_URGENT_SECONDS, + MARGIN_WARNING_PERCENT, + PASSING_SUPPORT_PERCENT, + closingChip, + computeOverBudgetMap, + marginChip, +} from './governanceMeta'; + +describe('closingChip', () => { + const DEADLINE_SEC = 1_700_000_000; + const DEADLINE_MS = DEADLINE_SEC * 1000; + + test('returns null when the deadline is missing or malformed', () => { + expect(closingChip({ votingDeadline: undefined, nowMs: DEADLINE_MS })).toBeNull(); + expect(closingChip({ votingDeadline: 0, nowMs: DEADLINE_MS })).toBeNull(); + expect(closingChip({ votingDeadline: 'soon', nowMs: DEADLINE_MS })).toBeNull(); + }); + + test('returns null when the deadline has already passed', () => { + expect( + closingChip({ + votingDeadline: DEADLINE_SEC, + nowMs: DEADLINE_MS + 1000, + }) + ).toBeNull(); + }); + + test('returns null when the window is wider than the "closing soon" tier', () => { + const ahead = DEADLINE_MS - (CLOSING_SOON_SECONDS + 60) * 1000; + expect( + closingChip({ votingDeadline: DEADLINE_SEC, nowMs: ahead }) + ).toBeNull(); + }); + + test('labels the "soon" tier when the window is under a week but not urgent', () => { + const threeDays = 3 * 24 * 60 * 60; + const now = DEADLINE_MS - threeDays * 1000; + const chip = closingChip({ votingDeadline: DEADLINE_SEC, nowMs: now }); + expect(chip).not.toBeNull(); + expect(chip.kind).toBe('closing-soon'); + expect(chip.label).toMatch(/Closes in 3d/); + expect(chip.remainingSeconds).toBe(threeDays); + }); + + test('escalates to the "urgent" tier inside the 48h window', () => { + const oneHour = 60 * 60; + const now = DEADLINE_MS - oneHour * 1000; + const chip = closingChip({ votingDeadline: DEADLINE_SEC, nowMs: now }); + expect(chip).not.toBeNull(); + expect(chip.kind).toBe('closing-urgent'); + expect(chip.label).toMatch(/Closes in 1h/); + }); + + test('tier boundary: exactly CLOSING_URGENT_SECONDS away still reads as urgent', () => { + const now = DEADLINE_MS - CLOSING_URGENT_SECONDS * 1000; + const chip = closingChip({ votingDeadline: DEADLINE_SEC, nowMs: now }); + expect(chip.kind).toBe('closing-urgent'); + }); + + test('tier boundary: one second past the urgent window reads as "soon"', () => { + const now = DEADLINE_MS - (CLOSING_URGENT_SECONDS + 1) * 1000; + const chip = closingChip({ votingDeadline: DEADLINE_SEC, nowMs: now }); + expect(chip.kind).toBe('closing-soon'); + }); +}); + +describe('computeOverBudgetMap', () => { + // A 1000-MN network with a 100 SYS superblock budget — keeps + // the math trivial and lets the ranking cut be obvious at a glance. + const ENABLED = 1000; + const BUDGET = 100; + + function p(partial) { + return { + Key: partial.Key, + payment_amount: partial.payment_amount, + AbsoluteYesCount: partial.AbsoluteYesCount, + }; + } + + test('returns an empty map when inputs are missing or degenerate', () => { + expect( + computeOverBudgetMap({ + proposals: [], + enabledCount: ENABLED, + budget: BUDGET, + }).size + ).toBe(0); + expect( + computeOverBudgetMap({ + proposals: [p({ Key: 'a'.repeat(64), payment_amount: 50, AbsoluteYesCount: 500 })], + enabledCount: 0, + budget: BUDGET, + }).size + ).toBe(0); + expect( + computeOverBudgetMap({ + proposals: [p({ Key: 'a'.repeat(64), payment_amount: 50, AbsoluteYesCount: 500 })], + enabledCount: ENABLED, + budget: 0, + }).size + ).toBe(0); + }); + + test('ignores failing proposals when deciding who is over budget', () => { + // 12% support for the first (passing), 5% for the second (failing). + // Budget is 100 SYS but the only passing row requests 50 SYS, so + // no one is over budget. + const map = computeOverBudgetMap({ + proposals: [ + p({ Key: 'a'.repeat(64), payment_amount: 50, AbsoluteYesCount: 120 }), + p({ Key: 'b'.repeat(64), payment_amount: 200, AbsoluteYesCount: 50 }), + ], + enabledCount: ENABLED, + budget: BUDGET, + }); + expect(map.size).toBe(0); + }); + + test('flags the lowest-ranked passing proposal when the cumulative sum exceeds the budget', () => { + // Three passing proposals requesting 60 + 60 + 60 = 180 SYS + // against a 100 SYS ceiling. Ranked by AbsoluteYesCount descending: + // rank 1 (200): running=60 (within budget, no chip) + // rank 2 (150): running=120 (first past ceiling → chip) + // rank 3 (120): running=180 (still past ceiling → chip) + const map = computeOverBudgetMap({ + proposals: [ + p({ Key: 'a'.repeat(64), payment_amount: 60, AbsoluteYesCount: 150 }), + p({ Key: 'b'.repeat(64), payment_amount: 60, AbsoluteYesCount: 200 }), + p({ Key: 'c'.repeat(64), payment_amount: 60, AbsoluteYesCount: 120 }), + ], + enabledCount: ENABLED, + budget: BUDGET, + }); + expect(map.size).toBe(2); + expect(map.get('a'.repeat(64))).toMatchObject({ kind: 'over-budget' }); + expect(map.get('c'.repeat(64))).toMatchObject({ kind: 'over-budget' }); + expect(map.get('b'.repeat(64))).toBeUndefined(); + }); + + test('non-finite payment_amounts are clamped to 0 so the running total stays useful', () => { + // Proposal A has a malformed payment_amount (non-numeric + // string → NaN after Number()). Without the clamp, the running + // total becomes NaN and no downstream row ever registers as + // over budget. With the clamp, we treat the unknowable row as + // 0 and keep checking the rest of the list. + // + // Setup: three passing proposals ranked A(200) → B(150) → C(120). + // A has malformed amount; B and C each request 80 SYS against a + // 100 SYS ceiling. Expected over-budget rows: C (running = 0 + // + 80 + 80 = 160 > 100 at C). B stays inside the budget. + const map = computeOverBudgetMap({ + proposals: [ + { Key: 'a'.repeat(64), payment_amount: 'NOT_A_NUMBER', AbsoluteYesCount: 200 }, + { Key: 'b'.repeat(64), payment_amount: 80, AbsoluteYesCount: 150 }, + { Key: 'c'.repeat(64), payment_amount: 80, AbsoluteYesCount: 120 }, + ], + enabledCount: 1000, + budget: 100, + }); + expect(map.has('a'.repeat(64))).toBe(false); + expect(map.has('b'.repeat(64))).toBe(false); + expect(map.has('c'.repeat(64))).toBe(true); + }); + + test('Keys are stored lowercased so callers can look up case-insensitively', () => { + const MIXED = `${'A'.repeat(32)}${'b'.repeat(32)}`; + const map = computeOverBudgetMap({ + proposals: [ + p({ Key: 'b'.repeat(64), payment_amount: 80, AbsoluteYesCount: 200 }), + p({ Key: MIXED, payment_amount: 80, AbsoluteYesCount: 150 }), + ], + enabledCount: ENABLED, + budget: BUDGET, + }); + expect(map.get(MIXED.toLowerCase())).toMatchObject({ kind: 'over-budget' }); + }); +}); + +describe('marginChip', () => { + test('returns null when enabledCount is unknown', () => { + expect( + marginChip({ + proposal: { AbsoluteYesCount: 100 }, + enabledCount: 0, + }) + ).toBeNull(); + }); + + test('returns null outside the margin band', () => { + // 15% support — well above the 10% line and outside the + // 1.5% window. + expect( + marginChip({ + proposal: { AbsoluteYesCount: 150 }, + enabledCount: 1000, + }) + ).toBeNull(); + // 8.4% support — below the line and outside the window. + expect( + marginChip({ + proposal: { AbsoluteYesCount: 84 }, + enabledCount: 1000, + }) + ).toBeNull(); + }); + + test('above-line, within margin → margin-thin tone', () => { + // 11% support → 1% above the line. + const chip = marginChip({ + proposal: { AbsoluteYesCount: 110 }, + enabledCount: 1000, + }); + expect(chip).not.toBeNull(); + expect(chip.kind).toBe('margin-thin'); + expect(chip.label).toMatch(/slim margin/i); + }); + + test('below-line, within margin → margin-near tone', () => { + // 9% support → 1% below the line. + const chip = marginChip({ + proposal: { AbsoluteYesCount: 90 }, + enabledCount: 1000, + }); + expect(chip).not.toBeNull(); + expect(chip.kind).toBe('margin-near'); + expect(chip.label).toMatch(/close to passing/i); + }); + + test('boundary: exactly MARGIN_WARNING_PERCENT away still lights the chip', () => { + const above = marginChip({ + proposal: { + AbsoluteYesCount: (PASSING_SUPPORT_PERCENT + MARGIN_WARNING_PERCENT) * 10, + }, + enabledCount: 1000, + }); + expect(above).not.toBeNull(); + expect(above.kind).toBe('margin-thin'); + }); + + test('support at exactly the 10% threshold reads as "Close to passing" (matches ProposalRow\'s support > 10 pass check)', () => { + // Core's pass logic is strict >10%, so a row at exactly 10% + // is NOT passing. The chip must agree — otherwise the row + // shows "not enough votes" paired with "just above the pass + // threshold" copy, which is confusing. + const chip = marginChip({ + proposal: { AbsoluteYesCount: 100 }, + enabledCount: 1000, + }); + expect(chip).not.toBeNull(); + expect(chip.kind).toBe('margin-near'); + expect(chip.label).toMatch(/close to passing/i); + }); +}); + +describe('tier constants are sane relative to each other', () => { + // Guard against someone accidentally reordering the closing + // tiers — urgent must be a tighter window than "soon". + test('urgent window is a strict subset of the closing-soon window', () => { + expect(CLOSING_URGENT_SECONDS).toBeLessThan(CLOSING_SOON_SECONDS); + }); +}); diff --git a/src/lib/governanceOps.js b/src/lib/governanceOps.js new file mode 100644 index 0000000..e1eefba --- /dev/null +++ b/src/lib/governanceOps.js @@ -0,0 +1,162 @@ +// Pure helpers that roll the governance feed + per-proposal receipt +// summary + owned-MN count up into the ops-summary hero at the top +// of the authenticated Governance page. +// +// Why this file exists separately from governanceCohort.js: +// +// * cohortChip gives a per-row *label* — it's UI vocabulary. +// * This module gives page-wide *counts* — it's dashboard vocabulary. +// * Both need to agree on "which proposals count as voted" so the +// per-row chips and the hero don't contradict each other. Rather +// than duplicate the classification across components, we export +// `classifyProposal` here and reuse it from both call-sites over +// time; today it's the single source of truth for the hero. +// +// Design notes: +// +// * Voted + Pending both count as "voted" for hero purposes. A +// pending receipt means the user did their part — the vote is +// signed and relayed; Core just hasn't echoed it back in +// `gobject_getcurrentvotes` yet. Telling the user "you still +// need to vote" while we're waiting for chain confirmation +// would be a lie. +// +// * Changed + Needs-retry + Partial + Not-voted count as "needs +// vote". All four states mean the user has work to do; we +// aggregate them into a single denominator so the hero stays a +// two-bucket read (done vs. to-do). +// +// * Passing uses the same >10% threshold as the per-row status +// chip. If the threshold changes it should change in one place; +// for now we keep the 10 literal close to the consumer so the +// hero stays independent. +// +// * `nextUnvotedKey` walks the provided proposals in caller order. +// The caller is expected to pass them in display order (i.e. +// already sorted + filtered the same way the table renders). +// That keeps the jump-link deterministic: click jumps to the +// literal next row the user can see. + +import { cohortChip } from './governanceCohort'; + +// Absolute-yes threshold (percent of enabled MNs) for "passing" — +// mirrors the per-row badge logic so the hero count agrees with +// what the table visualises. +const PASSING_SUPPORT_PERCENT = 10; + +function supportPercent(proposal, enabledCount) { + if (!Number.isFinite(enabledCount) || enabledCount <= 0) return 0; + const absYes = Number(proposal && proposal.AbsoluteYesCount); + if (!Number.isFinite(absYes)) return 0; + return (absYes / enabledCount) * 100; +} + +function hashKeyOf(proposal) { + if (!proposal || typeof proposal.Key !== 'string') return ''; + return proposal.Key.toLowerCase(); +} + +// Decide which hero bucket a proposal falls into for this user. +// Returns one of: 'voted' | 'needs-vote' | 'not-applicable'. +// +// 'not-applicable' means we shouldn't count this proposal in the +// progress-bar denominator — e.g. the user owns zero MNs and +// therefore can't vote on anything, or the cohort classifier was +// unable to return a chip (returned null). +export function classifyProposal(proposal, summaryMap, ownedCount) { + const key = hashKeyOf(proposal); + const summaryRow = key && summaryMap ? summaryMap.get(key) || null : null; + const chip = cohortChip(summaryRow, ownedCount); + + if (!chip) return 'not-applicable'; + if (chip.kind === 'voted' || chip.kind === 'pending') return 'voted'; + // not-voted / needs-retry / changed / partial all mean "user has + // something to do here". We don't distinguish them at the hero + // level; the per-row chip still tells the detailed story. + return 'needs-vote'; +} + +// Assemble the hero-level counts from the feed + summary + owned. +// +// `enabledCount` drives the "passing" calculation; when it's +// missing (network-stats fetch failed or in-flight) we still +// compute everything except the passing/watch split, which become +// null. Callers can render "—" for those two chips rather than +// miscount a proposal as "not passing" just because we don't know +// the denominator yet. +// +// `proposals` is expected to be the already-filtered list the page +// renders — i.e. honour the current search/filter. Scope of the +// hero is "what you see below me", so passing the raw unfiltered +// feed would have the hero contradict the table. +export function computeOpsStats({ + proposals, + summaryMap, + ownedCount, + enabledCount, +}) { + const list = Array.isArray(proposals) ? proposals : []; + const total = list.length; + let voted = 0; + let needsVote = 0; + let applicable = 0; + let passing = null; + let watching = null; + let nextUnvotedKey = null; + + const enabledIsKnown = Number.isFinite(enabledCount) && enabledCount > 0; + if (enabledIsKnown) { + passing = 0; + watching = 0; + } + + for (const proposal of list) { + if (enabledIsKnown) { + if (supportPercent(proposal, enabledCount) > PASSING_SUPPORT_PERCENT) { + passing += 1; + } else { + watching += 1; + } + } + + const bucket = classifyProposal(proposal, summaryMap, ownedCount); + if (bucket === 'not-applicable') continue; + applicable += 1; + if (bucket === 'voted') { + voted += 1; + } else { + needsVote += 1; + if (!nextUnvotedKey && typeof proposal.Key === 'string') { + nextUnvotedKey = proposal.Key; + } + } + } + + // Progress percent is computed off "applicable" so a user with + // zero owned MNs sees a hero that doesn't pretend to measure + // their participation. Same reason we don't divide by `total` + // unconditionally: cohort chips only appear for proposals the + // user can act on, and the hero should match. + let progressPercent = null; + if (applicable > 0) { + progressPercent = Math.round((voted / applicable) * 100); + } + + return { + total, + applicable, + voted, + needsVote, + passing, + watching, + progressPercent, + nextUnvotedKey, + ownedCount: Number.isInteger(ownedCount) ? ownedCount : null, + }; +} + +export const __private__ = { + PASSING_SUPPORT_PERCENT, + supportPercent, + hashKeyOf, +}; diff --git a/src/lib/governanceOps.test.js b/src/lib/governanceOps.test.js new file mode 100644 index 0000000..7bb01bc --- /dev/null +++ b/src/lib/governanceOps.test.js @@ -0,0 +1,244 @@ +import { classifyProposal, computeOpsStats } from './governanceOps'; + +function mkProposal({ key, absoluteYes = 0 }) { + return { Key: key, AbsoluteYesCount: absoluteYes }; +} + +function mkSummaryMap(rows) { + const m = new Map(); + for (const r of rows) { + m.set(r.proposalHash.toLowerCase(), r); + } + return m; +} + +function baseRow(overrides) { + return { + proposalHash: '', + total: 0, + relayed: 0, + confirmed: 0, + stale: 0, + failed: 0, + confirmedYes: 0, + confirmedNo: 0, + confirmedAbstain: 0, + latestSubmittedAt: null, + latestVerifiedAt: null, + ...overrides, + }; +} + +describe('classifyProposal', () => { + const proposal = mkProposal({ key: 'a'.repeat(64), absoluteYes: 100 }); + + test('no summary row + owned MNs → needs-vote', () => { + const map = mkSummaryMap([]); + expect(classifyProposal(proposal, map, 2)).toBe('needs-vote'); + }); + + test('no summary row + zero owned MNs → not-applicable', () => { + const map = mkSummaryMap([]); + expect(classifyProposal(proposal, map, 0)).toBe('not-applicable'); + }); + + test('no summary row + unknown owned count → not-applicable', () => { + const map = mkSummaryMap([]); + expect(classifyProposal(proposal, map, null)).toBe('not-applicable'); + }); + + test('all confirmed = voted bucket', () => { + const map = mkSummaryMap([ + baseRow({ + proposalHash: 'a'.repeat(64), + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + ]); + expect(classifyProposal(proposal, map, 2)).toBe('voted'); + }); + + test('pending (relayed only) = voted bucket', () => { + // User's done the work; we just wait for chain echo. + const map = mkSummaryMap([ + baseRow({ + proposalHash: 'a'.repeat(64), + total: 2, + relayed: 2, + }), + ]); + expect(classifyProposal(proposal, map, 2)).toBe('voted'); + }); + + test('partial (ownedCount > total, all confirmed) = needs-vote', () => { + const map = mkSummaryMap([ + baseRow({ + proposalHash: 'a'.repeat(64), + total: 1, + confirmed: 1, + confirmedYes: 1, + }), + ]); + expect(classifyProposal(proposal, map, 3)).toBe('needs-vote'); + }); + + test('any failed row = needs-vote (retry)', () => { + const map = mkSummaryMap([ + baseRow({ + proposalHash: 'a'.repeat(64), + total: 2, + confirmed: 1, + confirmedYes: 1, + failed: 1, + }), + ]); + expect(classifyProposal(proposal, map, 2)).toBe('needs-vote'); + }); + + test('any stale row = needs-vote (changed)', () => { + const map = mkSummaryMap([ + baseRow({ + proposalHash: 'a'.repeat(64), + total: 2, + confirmed: 1, + confirmedYes: 1, + stale: 1, + }), + ]); + expect(classifyProposal(proposal, map, 2)).toBe('needs-vote'); + }); +}); + +describe('computeOpsStats', () => { + const p1 = mkProposal({ key: 'a'.repeat(64), absoluteYes: 200 }); + const p2 = mkProposal({ key: 'b'.repeat(64), absoluteYes: 50 }); + const p3 = mkProposal({ key: 'c'.repeat(64), absoluteYes: 300 }); + + test('empty proposal list', () => { + const stats = computeOpsStats({ + proposals: [], + summaryMap: new Map(), + ownedCount: 5, + enabledCount: 1000, + }); + expect(stats).toMatchObject({ + total: 0, + applicable: 0, + voted: 0, + needsVote: 0, + passing: 0, + watching: 0, + progressPercent: null, + nextUnvotedKey: null, + ownedCount: 5, + }); + }); + + test('counts voted, needs-vote, and passing buckets', () => { + // enabledCount=1000 → 10% threshold = absoluteYes > 100 to pass. + const map = mkSummaryMap([ + baseRow({ + proposalHash: p1.Key, + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + // p2 has no receipts → needs-vote (user owns MNs) + baseRow({ + proposalHash: p3.Key, + total: 2, + confirmed: 1, + confirmedYes: 1, + failed: 1, + }), + ]); + + const stats = computeOpsStats({ + proposals: [p1, p2, p3], + summaryMap: map, + ownedCount: 2, + enabledCount: 1000, + }); + expect(stats.total).toBe(3); + expect(stats.applicable).toBe(3); + expect(stats.voted).toBe(1); + expect(stats.needsVote).toBe(2); + expect(stats.passing).toBe(2); // p1 (200/1000=20%) + p3 (300/1000=30%) + expect(stats.watching).toBe(1); // p2 (50/1000=5%) + // First needs-vote proposal in display order is p2. + expect(stats.nextUnvotedKey).toBe(p2.Key); + expect(stats.progressPercent).toBe(33); + }); + + test('jump link points to the first display-order needs-vote proposal', () => { + // Same summary, different caller order — jump follows caller order. + const map = mkSummaryMap([ + baseRow({ + proposalHash: p1.Key, + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + ]); + const stats = computeOpsStats({ + proposals: [p3, p2, p1], + summaryMap: map, + ownedCount: 2, + enabledCount: 1000, + }); + expect(stats.nextUnvotedKey).toBe(p3.Key); + }); + + test('no owned MNs → everything is not-applicable, progress is null', () => { + const stats = computeOpsStats({ + proposals: [p1, p2, p3], + summaryMap: new Map(), + ownedCount: 0, + enabledCount: 1000, + }); + expect(stats.applicable).toBe(0); + expect(stats.voted).toBe(0); + expect(stats.needsVote).toBe(0); + expect(stats.progressPercent).toBeNull(); + expect(stats.nextUnvotedKey).toBeNull(); + expect(stats.passing).toBe(2); + expect(stats.watching).toBe(1); + }); + + test('unknown enabledCount leaves passing/watching as null', () => { + const stats = computeOpsStats({ + proposals: [p1, p2], + summaryMap: new Map(), + ownedCount: 2, + enabledCount: null, + }); + expect(stats.passing).toBeNull(); + expect(stats.watching).toBeNull(); + }); + + test('all voted → nextUnvotedKey is null and progress is 100', () => { + const map = mkSummaryMap([ + baseRow({ + proposalHash: p1.Key, + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + baseRow({ + proposalHash: p2.Key, + total: 2, + confirmed: 2, + confirmedYes: 2, + }), + ]); + const stats = computeOpsStats({ + proposals: [p1, p2], + summaryMap: map, + ownedCount: 2, + enabledCount: 1000, + }); + expect(stats.nextUnvotedKey).toBeNull(); + expect(stats.progressPercent).toBe(100); + }); +}); diff --git a/src/lib/governanceService.js b/src/lib/governanceService.js index 0e3e0dc..c65b199 100644 --- a/src/lib/governanceService.js +++ b/src/lib/governanceService.js @@ -64,6 +64,7 @@ const VOTE_PATH = '/gov/vote'; const RECEIPTS_PATH = '/gov/receipts'; const RECEIPTS_RECONCILE_PATH = '/gov/receipts/reconcile'; const RECEIPTS_SUMMARY_PATH = '/gov/receipts/summary'; +const RECEIPTS_RECENT_PATH = '/gov/receipts/recent'; const HEX64_RE = /^[0-9a-fA-F]{64}$/; function govError(code, status, cause) { @@ -292,12 +293,43 @@ export function createGovernanceService(client = defaultClient) { } } + // Fetch the caller's most-recent receipts across all proposals. + // Drives the "Your activity" card on the authenticated Governance + // page. Pure read — no RPC, no reconciliation; rows reflect + // whatever the background reconciler last wrote. + // + // The server clamps `limit` to [1, 50]. We expose it here as an + // option and let the backend do the clamp; passing a value + // outside [1, 50] is allowed at the service layer (backend + // handles it) but we still pre-validate that it's a positive int + // so a UI bug doesn't send `NaN` or negative values. + async function fetchRecentReceipts({ limit } = {}) { + const params = {}; + if (limit !== undefined && limit !== null) { + if (!Number.isInteger(limit) || limit <= 0) { + throw govError('invalid_limit', 0); + } + params.limit = limit; + } + try { + const res = await client.get(RECEIPTS_RECENT_PATH, { params }); + const data = res.data || {}; + return { + receipts: Array.isArray(data.receipts) ? data.receipts : [], + }; + } catch (err) { + if (err && err.code) throw err; + throw govError('network_error', 0, err); + } + } + return { lookupOwnedMasternodes, submitVote, fetchReceipts, reconcileReceipts, fetchReceiptsSummary, + fetchRecentReceipts, }; } diff --git a/src/lib/governanceService.test.js b/src/lib/governanceService.test.js index c921ef2..b231cdd 100644 --- a/src/lib/governanceService.test.js +++ b/src/lib/governanceService.test.js @@ -376,3 +376,81 @@ describe('governanceService.fetchReceiptsSummary', () => { }); }); }); + +describe('governanceService.fetchRecentReceipts', () => { + test('GETs /gov/receipts/recent and returns the raw row list', async () => { + const { service, adapter } = makeService(); + adapter.onGet('/gov/receipts/recent').reply(200, { + receipts: [ + { + id: 1, + proposalHash: H64('a'), + collateralHash: H64('b'), + collateralIndex: 0, + voteOutcome: 'yes', + voteSignal: 'funding', + voteTime: 1_700_000_000, + status: 'confirmed', + lastError: null, + submittedAt: 1_700_000_050_000, + verifiedAt: 1_700_000_080_000, + }, + ], + }); + const out = await service.fetchRecentReceipts(); + expect(out.receipts).toHaveLength(1); + expect(out.receipts[0].status).toBe('confirmed'); + }); + + test('passes the limit param through to the server', async () => { + const { service, adapter } = makeService(); + adapter.onGet('/gov/receipts/recent').reply((config) => { + // Axios serialises `params` onto the query string for GETs; + // assert the server would have seen the integer we passed. + expect(config.params).toEqual({ limit: 5 }); + return [200, { receipts: [] }]; + }); + await service.fetchRecentReceipts({ limit: 5 }); + }); + + test('omits the limit param when the caller passes nothing', async () => { + // We want the backend's default (10) to apply rather than pinning + // a contract from the client. Pre-sending `limit=undefined` would + // serialise as "limit=" on the wire which would then fail the + // server's NaN guard. + const { service, adapter } = makeService(); + adapter.onGet('/gov/receipts/recent').reply((config) => { + expect(config.params).toEqual({}); + return [200, { receipts: [] }]; + }); + await service.fetchRecentReceipts(); + }); + + test('rejects nonsense limits before making a request', async () => { + const { service } = makeService(); + await expect( + service.fetchRecentReceipts({ limit: 0 }) + ).rejects.toMatchObject({ code: 'invalid_limit' }); + await expect( + service.fetchRecentReceipts({ limit: -1 }) + ).rejects.toMatchObject({ code: 'invalid_limit' }); + await expect( + service.fetchRecentReceipts({ limit: 2.5 }) + ).rejects.toMatchObject({ code: 'invalid_limit' }); + }); + + test('defaults receipts to [] on a malformed body', async () => { + const { service, adapter } = makeService(); + adapter.onGet('/gov/receipts/recent').reply(200, {}); + const out = await service.fetchRecentReceipts(); + expect(out.receipts).toEqual([]); + }); + + test('maps network failures to network_error', async () => { + const { service, adapter } = makeService(); + adapter.onGet('/gov/receipts/recent').networkError(); + await expect(service.fetchRecentReceipts()).rejects.toMatchObject({ + code: 'network_error', + }); + }); +}); diff --git a/src/lib/governanceSupportShift.js b/src/lib/governanceSupportShift.js new file mode 100644 index 0000000..65390e2 --- /dev/null +++ b/src/lib/governanceSupportShift.js @@ -0,0 +1,158 @@ +// Pre-submit support-shift preview for the vote modal. +// +// Given the user's current selection (which MNs, which outcome) and +// each selected MN's prior confirmed receipt, compute what submitting +// would do to the proposal's net support tally. This lets the modal +// surface "Submitting will add +2 net support" or "Submitting will +// flip your earlier 1 yes vote to abstain (net −1)" *before* the +// user clicks, so vote changes are never silently made. +// +// Terminology: +// +// "Net support" mirrors Core's AbsoluteYesCount = YesCount − NoCount. +// Abstain doesn't count toward net support (it's a participation +// marker only). Abstain *does* count toward total participation, +// but we intentionally don't surface that in the preview because +// the proposal row's status chip ("Passing / Not passing") is +// driven by net support, not raw turnout — the delta we show +// has to match the chip the user sees next to the row. +// +// Input shape: +// +// entries: Array<{ currentOutcome, previousOutcome, previousStatus }> +// +// currentOutcome 'yes' | 'no' | 'abstain' — the user's pick in +// the modal for this MN. One value shared +// across all selected rows today, but the +// helper doesn't care; callers could vary it +// per MN in the future. +// +// previousOutcome Prior receipt's voteOutcome, or '' / null if +// there's no prior receipt. +// +// previousStatus Prior receipt status. Only 'confirmed' rows +// are credited to the chain — 'relayed', 'stale', +// and 'failed' don't (and might not) count in +// AbsoluteYesCount yet, so they shouldn't be +// subtracted from the delta. We treat them as +// "no prior contribution to net support". +// +// Return shape: +// +// { +// netDelta, // signed integer; + favours the proposal +// yesDelta, // signed integer; YesCount delta +// noDelta, // signed integer; NoCount delta +// confirmedReplaced, // #entries whose confirmed yes/no is being +// // replaced by a different outcome +// abstainBenign, // #entries where both sides are abstain / +// // no prior vote / same no-op — included +// // for test coverage, not displayed today +// } +// +// Business rules: +// +// * A "confirmed → different" transition is flagged in +// confirmedReplaced. That's the interesting case for +// surfacing "this will change 2 prior votes". +// * A "confirmed → same" transition contributes 0 to delta and +// is NOT counted as replacement; the backend short-circuits +// these via the already_on_chain path so the user never +// actually re-spends an RPC call. +// * A non-confirmed prior (or no prior at all) is treated as +// "no contribution yet". Selecting yes adds +1; no adds −1; +// abstain adds 0. +// * Abstain→abstain and no-prior→abstain are benign no-ops for +// net support (yesDelta = noDelta = 0). + +function contributionFor(outcome) { + if (outcome === 'yes') return { yes: 1, no: 0 }; + if (outcome === 'no') return { yes: 0, no: 1 }; + // abstain and any unknown value => no participation toward net. + return { yes: 0, no: 0 }; +} + +export function computeSupportShift(entries) { + let yesDelta = 0; + let noDelta = 0; + let confirmedReplaced = 0; + let abstainBenign = 0; + + const list = Array.isArray(entries) ? entries : []; + for (const entry of list) { + const current = entry && entry.currentOutcome; + const prev = entry && entry.previousOutcome; + const prevIsConfirmed = + entry && entry.previousStatus === 'confirmed'; + + const now = contributionFor(current); + const before = prevIsConfirmed ? contributionFor(prev) : { yes: 0, no: 0 }; + + yesDelta += now.yes - before.yes; + noDelta += now.no - before.no; + + if (prevIsConfirmed && prev && prev !== current) { + confirmedReplaced += 1; + } + if (!prev && current === 'abstain') abstainBenign += 1; + if (prev === 'abstain' && current === 'abstain') abstainBenign += 1; + } + + return { + netDelta: yesDelta - noDelta, + yesDelta, + noDelta, + confirmedReplaced, + abstainBenign, + }; +} + +// Render-friendly descriptor for the support-shift preview. +// Returns null when there's nothing useful to say (e.g. nothing +// selected, or the selection is a pure no-op). Callers can +// directly bind the `tone` and `headline` into their UI. +export function describeSupportShift(shift, selectedCount) { + if (!shift || !Number.isInteger(selectedCount) || selectedCount <= 0) { + return null; + } + const { netDelta, confirmedReplaced } = shift; + const abs = Math.abs(netDelta); + const sign = netDelta > 0 ? '+' : netDelta < 0 ? '−' : '±'; + const tone = + netDelta > 0 ? 'positive' : netDelta < 0 ? 'negative' : 'neutral'; + + let headline; + if (netDelta === 0) { + headline = 'No net-support change'; + } else { + headline = `Net support ${sign}${abs}`; + } + + const detailBits = []; + if (confirmedReplaced > 0) { + detailBits.push( + `${confirmedReplaced} prior confirmed ${ + confirmedReplaced === 1 ? 'vote' : 'votes' + } will change` + ); + } + if (shift.yesDelta !== 0) { + const yAbs = Math.abs(shift.yesDelta); + detailBits.push( + `${shift.yesDelta > 0 ? '+' : '−'}${yAbs} yes` + ); + } + if (shift.noDelta !== 0) { + const nAbs = Math.abs(shift.noDelta); + detailBits.push( + `${shift.noDelta > 0 ? '+' : '−'}${nAbs} no` + ); + } + + return { + tone, + headline, + detail: detailBits.join(' · '), + netDelta, + }; +} diff --git a/src/lib/governanceSupportShift.test.js b/src/lib/governanceSupportShift.test.js new file mode 100644 index 0000000..6cb0493 --- /dev/null +++ b/src/lib/governanceSupportShift.test.js @@ -0,0 +1,180 @@ +import { + computeSupportShift, + describeSupportShift, +} from './governanceSupportShift'; + +describe('computeSupportShift', () => { + test('empty input returns a zero delta', () => { + expect(computeSupportShift([])).toEqual({ + netDelta: 0, + yesDelta: 0, + noDelta: 0, + confirmedReplaced: 0, + abstainBenign: 0, + }); + }); + + test('fresh yes votes move net support +N', () => { + const out = computeSupportShift([ + { currentOutcome: 'yes', previousOutcome: null, previousStatus: '' }, + { currentOutcome: 'yes', previousOutcome: null, previousStatus: '' }, + { currentOutcome: 'yes', previousOutcome: null, previousStatus: '' }, + ]); + expect(out.netDelta).toBe(3); + expect(out.yesDelta).toBe(3); + expect(out.noDelta).toBe(0); + expect(out.confirmedReplaced).toBe(0); + }); + + test('fresh no votes move net support −N', () => { + const out = computeSupportShift([ + { currentOutcome: 'no', previousOutcome: null, previousStatus: '' }, + { currentOutcome: 'no', previousOutcome: null, previousStatus: '' }, + ]); + expect(out.netDelta).toBe(-2); + expect(out.noDelta).toBe(2); + }); + + test('changing a confirmed yes to no moves net support by −2 and flags replacement', () => { + const out = computeSupportShift([ + { + currentOutcome: 'no', + previousOutcome: 'yes', + previousStatus: 'confirmed', + }, + ]); + expect(out.netDelta).toBe(-2); + expect(out.yesDelta).toBe(-1); + expect(out.noDelta).toBe(1); + expect(out.confirmedReplaced).toBe(1); + }); + + test('confirmed yes → yes (no-op) does not move support', () => { + const out = computeSupportShift([ + { + currentOutcome: 'yes', + previousOutcome: 'yes', + previousStatus: 'confirmed', + }, + ]); + expect(out.netDelta).toBe(0); + expect(out.confirmedReplaced).toBe(0); + }); + + test('relayed-but-not-confirmed prior is treated as no prior contribution', () => { + // The vote isn't observably on chain yet. We don't subtract it + // from the delta, or the preview would mis-report what the chain + // sees right now. + const out = computeSupportShift([ + { + currentOutcome: 'no', + previousOutcome: 'yes', + previousStatus: 'relayed', + }, + ]); + expect(out.netDelta).toBe(-1); + expect(out.confirmedReplaced).toBe(0); + }); + + test('abstain has no net-support impact', () => { + const out = computeSupportShift([ + { currentOutcome: 'abstain', previousOutcome: null, previousStatus: '' }, + { + currentOutcome: 'abstain', + previousOutcome: 'abstain', + previousStatus: 'confirmed', + }, + ]); + expect(out.netDelta).toBe(0); + expect(out.yesDelta).toBe(0); + expect(out.noDelta).toBe(0); + }); + + test('changing confirmed no → yes flips +2', () => { + const out = computeSupportShift([ + { + currentOutcome: 'yes', + previousOutcome: 'no', + previousStatus: 'confirmed', + }, + ]); + expect(out.netDelta).toBe(2); + expect(out.yesDelta).toBe(1); + expect(out.noDelta).toBe(-1); + expect(out.confirmedReplaced).toBe(1); + }); + + test('mixing fresh and replacement entries sums correctly', () => { + // 3 fresh yes (+3), 2 confirmed-yes-to-no (−4), 1 confirmed-abstain + // kept as yes (+1, not counted as replacement since abstain + // contributes 0 to net). + const out = computeSupportShift([ + { currentOutcome: 'yes', previousOutcome: null, previousStatus: '' }, + { currentOutcome: 'yes', previousOutcome: null, previousStatus: '' }, + { currentOutcome: 'yes', previousOutcome: null, previousStatus: '' }, + { + currentOutcome: 'no', + previousOutcome: 'yes', + previousStatus: 'confirmed', + }, + { + currentOutcome: 'no', + previousOutcome: 'yes', + previousStatus: 'confirmed', + }, + { + currentOutcome: 'yes', + previousOutcome: 'abstain', + previousStatus: 'confirmed', + }, + ]); + // yes: +3 fresh +1 abstain→yes −2 (yes→no) = +2 + // no: +2 (yes→no) + // net = 2 − 2 = 0 + expect(out.yesDelta).toBe(2); + expect(out.noDelta).toBe(2); + expect(out.netDelta).toBe(0); + // 2 yes→no + 1 abstain→yes = 3 confirmed rows being replaced. + expect(out.confirmedReplaced).toBe(3); + }); +}); + +describe('describeSupportShift', () => { + test('returns null when no entries are selected', () => { + expect(describeSupportShift({ netDelta: 0, yesDelta: 0, noDelta: 0 }, 0)) + .toBeNull(); + expect(describeSupportShift(null, 0)).toBeNull(); + }); + + test('reports a positive-tone +N headline for net gains', () => { + const d = describeSupportShift( + { netDelta: 3, yesDelta: 3, noDelta: 0, confirmedReplaced: 0 }, + 3 + ); + expect(d.tone).toBe('positive'); + expect(d.headline).toMatch(/\+\s*3/); + expect(d.detail).toMatch(/\+3 yes/); + }); + + test('reports a negative-tone −N headline with replacement detail', () => { + const d = describeSupportShift( + { netDelta: -2, yesDelta: -1, noDelta: 1, confirmedReplaced: 1 }, + 1 + ); + expect(d.tone).toBe('negative'); + expect(d.headline).toMatch(/−\s*2/); + expect(d.detail).toMatch(/1 prior confirmed vote will change/i); + expect(d.detail).toMatch(/−1 yes/); + expect(d.detail).toMatch(/\+1 no/); + }); + + test('reports a neutral "no net-support change" for abstain-only or replacement no-ops', () => { + const d = describeSupportShift( + { netDelta: 0, yesDelta: 0, noDelta: 0, confirmedReplaced: 0 }, + 2 + ); + expect(d.tone).toBe('neutral'); + expect(d.headline).toMatch(/no net-support change/i); + expect(d.detail).toBe(''); + }); +}); diff --git a/src/pages/Governance.js b/src/pages/Governance.js index 51c9f49..4c237cd 100644 --- a/src/pages/Governance.js +++ b/src/pages/Governance.js @@ -1,13 +1,20 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import DataState from '../components/DataState'; +import GovernanceActivity from '../components/GovernanceActivity'; +import GovernanceOpsHero from '../components/GovernanceOpsHero'; import PageMeta from '../components/PageMeta'; import ProposalVoteModal from '../components/ProposalVoteModal'; import { useAuth } from '../context/AuthContext'; import useGovernanceData from '../hooks/useGovernanceData'; import { useGovernanceReceipts } from '../hooks/useGovernanceReceipts'; import { cohortChip } from '../lib/governanceCohort'; +import { + closingChip, + computeOverBudgetMap, + marginChip, +} from '../lib/governanceMeta'; import { formatCompactNumber, formatDayMonth, @@ -42,12 +49,49 @@ import { // legacy paths without an equivalent replacement was a repeat // complaint.) +// DOM id format used to let the ops hero jump-link scroll to a +// specific proposal row. Governance hashes are case-insensitive +// hex; lowercasing matches the normalisation used by the summary +// map and keeps the id stable across re-renders. +export function proposalRowDomId(key) { + if (typeof key !== 'string' || !key) return ''; + return `proposal-row-${key.toLowerCase()}`; +} + +// How fresh a `latestVerifiedAt` must be for the row to render the +// "Verified on-chain" pill. Matches the backend's +// DEFAULT_RECEIPTS_FRESHNESS_MS window (2 minutes) relaxed slightly +// to cover UI-side drift — anything older than ~5 minutes we treat +// as "probably still correct but not a confident live read" and +// fall back to the plain cohort chip. +const VERIFIED_FRESHNESS_MS = 5 * 60 * 1000; + +// Small relative time helper scoped to the row. Purposely narrow: +// we only need to distinguish "just now" / "N minutes ago" for +// tooltip copy. A broader formatter lives locally in +// GovernanceActivity for its own use; if a third consumer arrives +// we should hoist a shared one into lib/formatters. +function verifiedAgo(verifiedAt, nowMs) { + const ts = Number(verifiedAt); + if (!Number.isFinite(ts) || ts <= 0) return ''; + const now = Number.isFinite(nowMs) ? nowMs : Date.now(); + const diffSec = Math.max(0, Math.round((now - ts) / 1000)); + if (diffSec < 10) return 'just now'; + if (diffSec < 60) return `${diffSec}s ago`; + const mins = Math.round(diffSec / 60); + return `${mins}m ago`; +} + function ProposalRow({ proposal, enabledCount, isAuthenticated, onVote, cohort, + isHighlighted, + summaryRow, + metaChips, + nowMs, }) { const [feedback, setFeedback] = useState(''); const supportPercent = enabledCount @@ -93,8 +137,20 @@ function ProposalRow({ } } + const rowClasses = [ + 'proposal-row', + passing ? 'is-passing' : 'is-watch', + isHighlighted ? 'is-highlighted' : '', + ] + .filter(Boolean) + .join(' '); + return ( -
+
{statusLabel} @@ -109,6 +165,61 @@ function ProposalRow({ {cohort.label} ) : null} + {(() => { + // On-chain verified pill: a quiet "your vote is observed + // on-chain and was last checked ago" indicator. We + // deliberately only show it when (a) the user has at + // least one confirmed receipt for this proposal (i.e. the + // cohort chip already reads "Voted"), and (b) the last + // verification happened recently enough that it's still + // a meaningful confidence signal. Otherwise the row + // would shout "verified!" for rows the reconciler hasn't + // actually touched in an hour. + if (!summaryRow) return null; + const confirmed = Number(summaryRow.confirmed); + if (!(Number.isFinite(confirmed) && confirmed > 0)) return null; + const latestVerifiedAt = Number(summaryRow.latestVerifiedAt); + if (!Number.isFinite(latestVerifiedAt) || latestVerifiedAt <= 0) { + return null; + } + const now = Number.isFinite(nowMs) ? nowMs : Date.now(); + const age = now - latestVerifiedAt; + if (!(age >= 0 && age < VERIFIED_FRESHNESS_MS)) return null; + const ago = verifiedAgo(latestVerifiedAt, now); + const verifiedWhen = new Date(latestVerifiedAt).toUTCString(); + const tooltip = + `${confirmed} of your ${ + confirmed === 1 ? 'vote' : 'votes' + } were last observed on-chain ${ago} (${verifiedWhen}).`; + return ( + + + + Verified {ago} + + + ); + })()} + {Array.isArray(metaChips) && metaChips.length > 0 + ? metaChips.map((chip) => ( + + {chip.label} + + )) + : null}
@@ -198,10 +309,37 @@ function ProposalRow({ ); } +// How long the ops-hero "Jump to next" highlight stays visible on +// the target row before fading out. Long enough for the user's +// eye to land on it post-scroll; short enough that it doesn't +// stick around competing for attention if they then start +// interacting with the row. +const JUMP_HIGHLIGHT_MS = 2400; + export default function Governance() { const [filter, setFilter] = useState('all'); const [query, setQuery] = useState(''); const [voteProposal, setVoteProposal] = useState(null); + const [highlightKey, setHighlightKey] = useState(null); + // Bumping this token re-runs the "Your activity" fetch. We bump + // it when the vote modal closes so a freshly-submitted vote shows + // up in the activity list without forcing a full page reload. + const [activityRefreshToken, setActivityRefreshToken] = useState(0); + // Slow-ticking clock that drives time-sensitive derivations like + // the closing-window chip. Re-computing once per minute keeps the + // label accurate across long-lived sessions (e.g. "closing-soon" + // → "closing-urgent" transition at the 48h boundary, or the chip + // disappearing once the deadline passes) without hammering React + // — the chip rounds to minute / hour / day tiers, so sub-minute + // drift is invisible anyway. + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => { + setNowMs(Date.now()); + }, 60 * 1000); + return () => window.clearInterval(id); + }, []); + const highlightTimerRef = useRef(null); const { error, loading, @@ -216,12 +354,60 @@ export default function Governance() { const { summaryMap, ownedCount, refresh: refreshReceipts } = useGovernanceReceipts({ enabled: isAuthenticated }); + // Hash → proposal lookup for the activity card so receipts can + // render titles and the jump-link can route the user to an + // existing row. Built once per feed load; the activity card + // handles missing entries gracefully (receipt lands without a + // jump button). + const proposalsByHash = useMemo(() => { + const m = new Map(); + for (const p of proposals) { + if (p && typeof p.Key === 'string') { + m.set(p.Key.toLowerCase(), p); + } + } + return m; + }, [proposals]); + const networkStats = stats && stats.stats ? stats.stats.mn_stats : null; const superblockStats = stats && stats.stats ? stats.stats.superblock_stats : null; const enabledCount = parseNumber(networkStats && networkStats.enabled); const requestedBudget = proposals.reduce(function sumBudget(total, proposal) { return total + Number(proposal.payment_amount || 0); }, 0); + + // Feed-wide over-budget computation: precomputed once per render + // so each ProposalRow can look its chip up by hash in O(1) + // without reiterating the whole feed. Rebuilds on any change to + // proposals / enabledCount / budget; all three are referentially + // stable between fetches so the memo hit rate is high. + const overBudgetMap = useMemo( + () => + computeOverBudgetMap({ + proposals, + enabledCount, + budget: superblockStats ? superblockStats.budget : 0, + }), + [proposals, enabledCount, superblockStats] + ); + + // Per-row closing chip depends on the superblock deadline AND + // the current time, so it's memoized on both and re-derived once + // per minute via the `nowMs` ticker above. That ticker is what + // keeps long-lived sessions honest: the chip transitions from + // closing-soon → closing-urgent at the 48h boundary and + // disappears entirely once the deadline passes, even if the + // stats object isn't refreshed. Any hard-timed behaviour (e.g. + // disabling the vote button at the deadline) still lives on the + // server; this label is purely advisory. + const closing = useMemo( + () => + closingChip({ + votingDeadline: superblockStats ? superblockStats.voting_deadline : 0, + nowMs, + }), + [superblockStats, nowMs] + ); const visibleProposals = proposals.filter(function filterProposal(proposal) { const supportPercent = enabledCount ? (Number(proposal.AbsoluteYesCount || 0) / enabledCount) * 100 @@ -251,6 +437,90 @@ export default function Governance() { setVoteProposal(proposal); }, []); + // Smoothly scroll to a proposal by hash and briefly highlight + // it so the user's eye lands on the right row. + // + // Filter-aware: the activity card can surface jumps to proposals + // that are currently hidden by the search/filter switcher (e.g. + // the user filtered to "Passing" and then clicks a jump for a + // receipt on a watch-list proposal). Clicking a jump CTA that + // scrolls to nothing is a silent dead-end, which is the worst + // possible UX here — so before we look up the DOM id we clear + // any filter state that would be suppressing the target row, + // *only when* we can confirm the target exists in the full + // proposals list (we don't want to clobber the user's filter + // for a hash we wouldn't be able to show anyway). + // + // Scrolling happens inside a requestAnimationFrame so React has + // a chance to re-render the filtered list after the setState + // calls; otherwise the target row might not yet be in the DOM. + const jumpToProposal = useCallback((key) => { + if (typeof key !== 'string' || !key) return; + const normalizedKey = key.toLowerCase(); + const domId = proposalRowDomId(key); + if (!domId) return; + + const existsInFeed = proposals.some( + (p) => p && typeof p.Key === 'string' && p.Key.toLowerCase() === normalizedKey + ); + if (existsInFeed) { + // Only touch filter state if the target would otherwise be + // hidden. Keeps the user's current filter intact when they + // click a jump CTA on an already-visible row. + const visibleInFeed = visibleProposals.some( + (p) => + p && typeof p.Key === 'string' && p.Key.toLowerCase() === normalizedKey + ); + if (!visibleInFeed) { + setFilter('all'); + setQuery(''); + } + } + + const doScroll = () => { + if (typeof document === 'undefined') return; + const el = document.getElementById(domId); + if (!el || typeof el.scrollIntoView !== 'function') return; + try { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } catch (_e) { + // Older test environments / JSDOM may not accept the + // options object. Fall back to the plain scrollIntoView + // rather than throwing in a user-facing path. + try { + el.scrollIntoView(); + } catch (_e2) { + // Nothing else we can do; the highlight alone will + // still hint to the user that we jumped. + } + } + }; + + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(doScroll); + } else { + doScroll(); + } + + setHighlightKey(key); + if (highlightTimerRef.current) { + window.clearTimeout(highlightTimerRef.current); + } + highlightTimerRef.current = window.setTimeout(() => { + setHighlightKey(null); + highlightTimerRef.current = null; + }, JUMP_HIGHLIGHT_MS); + }, [proposals, visibleProposals]); + + useEffect(() => { + return () => { + if (highlightTimerRef.current) { + window.clearTimeout(highlightTimerRef.current); + highlightTimerRef.current = null; + } + }; + }, []); + const closeVoteModal = useCallback(() => { setVoteProposal(null); // Re-fetch the summary so the cohort chip reflects whatever @@ -263,6 +533,9 @@ export default function Governance() { // Swallow — non-critical UI freshness, no banner. }); } + // Same rationale for the activity card — a vote just closed so + // the "last 10" almost certainly changed. + setActivityRefreshToken((v) => v + 1); }, [isAuthenticated, refreshReceipts]); return ( @@ -347,6 +620,22 @@ export default function Governance() {

) : null} + {isAuthenticated && stats ? ( +
+ + +
+ ) : null} @@ -436,6 +725,20 @@ export default function Governance() { const cohort = isAuthenticated ? cohortChip(summaryRow, ownedCount) : null; + // Metadata chips are built per-row but derived + // from feed-wide state (closing deadline, budget + // ranking). Order matters: urgency first, budget + // pressure second, margin last — that's the + // order in which a scanning user needs to decide + // "do I care enough to click in?" + const rowMetaChips = []; + if (closing) rowMetaChips.push(closing); + const overBudget = hashKey + ? overBudgetMap.get(hashKey) + : null; + if (overBudget) rowMetaChips.push(overBudget); + const margin = marginChip({ proposal, enabledCount }); + if (margin) rowMetaChips.push(margin); return ( ); })} diff --git a/src/pages/Governance.test.js b/src/pages/Governance.test.js index 9785ce6..6bdd946 100644 --- a/src/pages/Governance.test.js +++ b/src/pages/Governance.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { MemoryRouter } from 'react-router-dom'; -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; // Mock strategy (same rationale as ProposalVoteModal.test.js — // avoids PBKDF2 / live axios). We stub the data hook and the two @@ -38,6 +38,42 @@ jest.mock('../components/ProposalVoteModal', () => (props) => ( )); +// Stub the hero / activity rail so the jump-callback tests can +// trigger jumpToProposal(key) directly via a test-only button. +// The real components are covered in their own files; we only +// need the prop wiring here. +jest.mock('../components/GovernanceOpsHero', () => (props) => ( +
+ +
+)); +jest.mock('../components/GovernanceActivity', () => (props) => ( +
+ +
+)); + // eslint-disable-next-line import/first import Governance from './Governance'; // eslint-disable-next-line import/first @@ -404,3 +440,500 @@ describe('Governance page — cohort chips', () => { expect(refresh).not.toHaveBeenCalled(); }); }); + +describe('Governance page — verified-on-chain pill', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + function summaryRow(partial) { + return { + proposalHash: 'a'.repeat(64), + total: 0, + relayed: 0, + confirmed: 0, + stale: 0, + failed: 0, + confirmedYes: 0, + confirmedNo: 0, + confirmedAbstain: 0, + latestSubmittedAt: 1700000000, + latestVerifiedAt: 1700000001, + ...partial, + }; + } + + test('renders the "Verified" pill when the user has a fresh confirmed receipt', () => { + // latestVerifiedAt within the freshness window (<5 min) — the + // reconciler has recently observed this user's on-chain votes + // for this proposal, so we surface a quiet confidence signal. + useAuth.mockReturnValue({ isAuthenticated: true, user: { id: 1 } }); + useGovernanceData.mockReturnValue( + baseData({ proposals: [makeProposal({ Key: 'a'.repeat(64) })] }) + ); + const now = Date.now(); + useGovernanceReceipts.mockReturnValue( + makeReceipts({ + summary: [ + summaryRow({ + total: 2, + confirmed: 2, + confirmedYes: 2, + latestVerifiedAt: now - 30_000, + }), + ], + ownedCount: 2, + }) + ); + + renderPage(); + + const pill = screen.getByTestId('proposal-row-verified'); + expect(pill).toBeInTheDocument(); + expect(pill.textContent).toMatch(/verified/i); + expect(pill.getAttribute('title')).toMatch( + /were last observed on-chain/i + ); + }); + + test('does not render when the confirmation is older than the freshness window', () => { + useAuth.mockReturnValue({ isAuthenticated: true, user: { id: 1 } }); + useGovernanceData.mockReturnValue( + baseData({ proposals: [makeProposal({ Key: 'a'.repeat(64) })] }) + ); + const now = Date.now(); + useGovernanceReceipts.mockReturnValue( + makeReceipts({ + summary: [ + summaryRow({ + total: 1, + confirmed: 1, + confirmedYes: 1, + // 1 hour old — well past the 5-minute window. + latestVerifiedAt: now - 60 * 60 * 1000, + }), + ], + ownedCount: 1, + }) + ); + + renderPage(); + + expect( + screen.queryByTestId('proposal-row-verified') + ).not.toBeInTheDocument(); + }); + + test('does not render when the user has no confirmed receipts for the proposal', () => { + // Failed-only / relayed-only receipts should NOT get the + // verified pill — that chip claims on-chain confirmation. + useAuth.mockReturnValue({ isAuthenticated: true, user: { id: 1 } }); + useGovernanceData.mockReturnValue( + baseData({ proposals: [makeProposal({ Key: 'a'.repeat(64) })] }) + ); + const now = Date.now(); + useGovernanceReceipts.mockReturnValue( + makeReceipts({ + summary: [ + summaryRow({ + total: 2, + failed: 2, + latestVerifiedAt: now - 30_000, + }), + ], + ownedCount: 2, + }) + ); + + renderPage(); + + expect( + screen.queryByTestId('proposal-row-verified') + ).not.toBeInTheDocument(); + }); + + test('anonymous visitors never see the Verified pill', () => { + useAuth.mockReturnValue({ isAuthenticated: false, user: null }); + useGovernanceData.mockReturnValue(baseData()); + const now = Date.now(); + useGovernanceReceipts.mockReturnValue( + makeReceipts({ + summary: [ + summaryRow({ + total: 1, + confirmed: 1, + confirmedYes: 1, + latestVerifiedAt: now - 30_000, + }), + ], + ownedCount: 1, + }) + ); + + renderPage(); + + expect( + screen.queryByTestId('proposal-row-verified') + ).not.toBeInTheDocument(); + }); +}); + +describe('Governance page — proposal metadata chips', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + test('closing-soon chip renders when the voting deadline is within a week', () => { + useAuth.mockReturnValue({ isAuthenticated: false, user: null }); + const threeDaysAhead = Math.floor(Date.now() / 1000) + 3 * 24 * 60 * 60; + useGovernanceData.mockReturnValue( + baseData({ + stats: { + stats: { + mn_stats: { enabled: 1000 }, + superblock_stats: { + budget: 1_000_000, + voting_deadline: threeDaysAhead, + superblock_date: threeDaysAhead + 60 * 60, + }, + }, + }, + }) + ); + + renderPage(); + + const chips = screen.getAllByTestId('proposal-row-meta-chip'); + const closing = chips.find( + (c) => c.getAttribute('data-meta-kind') === 'closing-soon' + ); + expect(closing).toBeDefined(); + expect(closing.textContent).toMatch(/Closes in/i); + }); + + test('closing chip escalates to urgent tone when the deadline is within 48h', () => { + useAuth.mockReturnValue({ isAuthenticated: false, user: null }); + const oneHourAhead = Math.floor(Date.now() / 1000) + 60 * 60; + useGovernanceData.mockReturnValue( + baseData({ + stats: { + stats: { + mn_stats: { enabled: 1000 }, + superblock_stats: { + budget: 1_000_000, + voting_deadline: oneHourAhead, + superblock_date: oneHourAhead + 60 * 60, + }, + }, + }, + }) + ); + + renderPage(); + + const chips = screen.getAllByTestId('proposal-row-meta-chip'); + const closing = chips.find( + (c) => c.getAttribute('data-meta-kind') === 'closing-urgent' + ); + expect(closing).toBeDefined(); + }); + + test('margin-thin chip renders when passing support is just over 10%', () => { + useAuth.mockReturnValue({ isAuthenticated: false, user: null }); + useGovernanceData.mockReturnValue( + baseData({ + // 10.5% support → 0.5% over the line → within the margin. + // No closing chip: the default baseData deadline is way in + // the past relative to now, so closingChip returns null. + proposals: [makeProposal({ AbsoluteYesCount: 105 })], + stats: { + stats: { + mn_stats: { enabled: 1000 }, + superblock_stats: { + budget: 1_000_000, + voting_deadline: 1, // far past → no closing chip + superblock_date: 1, + }, + }, + }, + }) + ); + + renderPage(); + + const chips = screen.getAllByTestId('proposal-row-meta-chip'); + expect(chips).toHaveLength(1); + expect(chips[0].getAttribute('data-meta-kind')).toBe('margin-thin'); + expect(chips[0].textContent).toMatch(/slim margin/i); + }); + + test('margin-near chip renders when support is just under 10%', () => { + useAuth.mockReturnValue({ isAuthenticated: false, user: null }); + useGovernanceData.mockReturnValue( + baseData({ + proposals: [makeProposal({ AbsoluteYesCount: 92 })], + stats: { + stats: { + mn_stats: { enabled: 1000 }, + superblock_stats: { + budget: 1_000_000, + voting_deadline: 1, + superblock_date: 1, + }, + }, + }, + }) + ); + + renderPage(); + + const chips = screen.getAllByTestId('proposal-row-meta-chip'); + expect(chips).toHaveLength(1); + expect(chips[0].getAttribute('data-meta-kind')).toBe('margin-near'); + }); + + test('over-budget chip only decorates the proposals below the ranking cutline', () => { + useAuth.mockReturnValue({ isAuthenticated: false, user: null }); + // Two passing proposals (12% and 15% support) each requesting + // 80 SYS against a 100 SYS ceiling. Rank 1 (15%) stays inside + // the budget; rank 2 (12%) sits past the cutline → over-budget. + const A = 'a'.repeat(64); + const B = 'b'.repeat(64); + useGovernanceData.mockReturnValue( + baseData({ + proposals: [ + makeProposal({ + Key: A, + title: 'Top', + AbsoluteYesCount: 150, + payment_amount: '80', + }), + makeProposal({ + Key: B, + title: 'Tail', + AbsoluteYesCount: 120, + payment_amount: '80', + }), + ], + stats: { + stats: { + mn_stats: { enabled: 1000 }, + superblock_stats: { + budget: 100, + voting_deadline: 1, + superblock_date: 1, + }, + }, + }, + }) + ); + + renderPage(); + + // Find every row and check its meta-chip set. The top-ranked + // row should NOT have an over-budget chip; the tail one should. + const allChips = screen.queryAllByTestId('proposal-row-meta-chip'); + const overBudgetKinds = allChips + .filter((c) => c.getAttribute('data-meta-kind') === 'over-budget'); + expect(overBudgetKinds).toHaveLength(1); + }); +}); + +describe('Governance page — time-sensitive chips refresh on long-lived sessions', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + test('closing chip escalates from "soon" to "urgent" as the deadline approaches without a stats refresh', () => { + useAuth.mockReturnValue({ isAuthenticated: false, user: null }); + // Deadline 72h out. Under soon threshold (7d) but above urgent + // threshold (48h) → starts as closing-soon. + const deadlineSec = Math.floor(Date.now() / 1000) + 72 * 60 * 60; + useGovernanceData.mockReturnValue( + baseData({ + stats: { + stats: { + mn_stats: { enabled: 1000 }, + superblock_stats: { + budget: 1_000_000, + voting_deadline: deadlineSec, + superblock_date: deadlineSec + 60 * 60, + }, + }, + }, + }) + ); + + renderPage(); + { + const chips = screen.getAllByTestId('proposal-row-meta-chip'); + const closing = chips.find((c) => + c.getAttribute('data-meta-kind').startsWith('closing-') + ); + expect(closing.getAttribute('data-meta-kind')).toBe('closing-soon'); + } + + // Advance wall-clock past the 48h urgency line WITHOUT mutating + // the stats object. The tick should still demote the chip. + act(() => { + jest.setSystemTime(Date.now() + 25 * 60 * 60 * 1000); // +25h → 47h remaining + jest.advanceTimersByTime(60 * 1000 + 10); // one ticker interval + }); + + { + const chips = screen.getAllByTestId('proposal-row-meta-chip'); + const closing = chips.find((c) => + c.getAttribute('data-meta-kind').startsWith('closing-') + ); + expect(closing.getAttribute('data-meta-kind')).toBe('closing-urgent'); + } + }); + + test('closing chip disappears after the deadline passes even without a stats refresh', () => { + useAuth.mockReturnValue({ isAuthenticated: false, user: null }); + const deadlineSec = Math.floor(Date.now() / 1000) + 5 * 60; // 5m away + useGovernanceData.mockReturnValue( + baseData({ + stats: { + stats: { + mn_stats: { enabled: 1000 }, + superblock_stats: { + budget: 1_000_000, + voting_deadline: deadlineSec, + superblock_date: deadlineSec + 60 * 60, + }, + }, + }, + }) + ); + + renderPage(); + // Deadline not yet passed → chip present (urgent tier). + expect( + screen + .getAllByTestId('proposal-row-meta-chip') + .some((c) => c.getAttribute('data-meta-kind') === 'closing-urgent') + ).toBe(true); + + // Jump 10 minutes ahead and tick the clock — now the deadline + // is in the past and the chip should be gone. + act(() => { + jest.setSystemTime(Date.now() + 10 * 60 * 1000); + jest.advanceTimersByTime(60 * 1000 + 10); + }); + + expect( + screen + .queryAllByTestId('proposal-row-meta-chip') + .some((c) => String(c.getAttribute('data-meta-kind')).startsWith('closing-')) + ).toBe(false); + }); +}); + +describe('Governance page — jumpToProposal filter-aware behaviour', () => { + afterEach(() => { + jest.clearAllMocks(); + if (typeof global !== 'undefined') { + delete global.__ACTIVITY_STUB_JUMP_KEY__; + } + }); + + test('jumping to a proposal that is hidden by the "Watch" filter clears the filter so the row becomes mountable', () => { + // Two proposals: + // - "passing" (12% support) → visible under the Passing filter + // - "watch" (5% support) → hidden under the Passing filter + // The activity card surfaces a jump to the watch-list proposal + // while the user has "Passing" selected. Without the fix the + // click is a silent no-op because the row isn't in the DOM. + // With the fix, filter clears back to "All" so the row mounts + // and the highlight attribute eventually fires. + const PASSING = 'a'.repeat(64); + const WATCH = 'b'.repeat(64); + useAuth.mockReturnValue({ isAuthenticated: true, user: { id: 1 } }); + useGovernanceData.mockReturnValue( + baseData({ + proposals: [ + makeProposal({ + Key: PASSING, + title: 'Passing one', + AbsoluteYesCount: 120, + }), + makeProposal({ + Key: WATCH, + title: 'Watch one', + AbsoluteYesCount: 50, + }), + ], + stats: { + stats: { + mn_stats: { enabled: 1000 }, + superblock_stats: { + budget: 1_000_000, + voting_deadline: 1, + superblock_date: 1, + }, + }, + }, + }) + ); + useGovernanceReceipts.mockReturnValue( + makeReceipts({ summary: [], ownedCount: 1 }) + ); + + renderPage(); + + // Switch to the Passing filter — Watch proposal is now hidden. + fireEvent.click(screen.getByRole('button', { name: /^passing$/i })); + expect(document.getElementById(`proposal-row-${WATCH}`)).toBeNull(); + expect(document.getElementById(`proposal-row-${PASSING}`)).not.toBeNull(); + + // Simulate the activity card asking us to jump to the hidden + // Watch proposal. The stub reads the target key from a global + // so we can pick the hash per test. + global.__ACTIVITY_STUB_JUMP_KEY__ = WATCH; + fireEvent.click(screen.getByTestId('activity-stub-jump')); + + // Filter clears → the hidden row becomes mountable again. + // We assert on the DOM id directly rather than waiting for + // the requestAnimationFrame-scheduled scroll, because JSDOM + // commits the setState synchronously but rAF-scheduled reads + // would require a fake-timer dance here. + expect(document.getElementById(`proposal-row-${WATCH}`)).not.toBeNull(); + }); + + test('jumping to a proposal that is already visible leaves the filter untouched', () => { + // Two proposals both passing; we stay on the "Passing" filter + // and jump to one of them. The filter shouldn't reset to "All" + // behind the user's back — the target row is already mounted. + const A = 'a'.repeat(64); + const B = 'b'.repeat(64); + useAuth.mockReturnValue({ isAuthenticated: true, user: { id: 1 } }); + useGovernanceData.mockReturnValue( + baseData({ + proposals: [ + makeProposal({ Key: A, title: 'A', AbsoluteYesCount: 150 }), + makeProposal({ Key: B, title: 'B', AbsoluteYesCount: 130 }), + ], + }) + ); + useGovernanceReceipts.mockReturnValue( + makeReceipts({ summary: [], ownedCount: 1 }) + ); + + renderPage(); + + const passingBtn = screen.getByRole('button', { name: /^passing$/i }); + fireEvent.click(passingBtn); + expect(passingBtn.className).toMatch(/is-active/); + + global.__ACTIVITY_STUB_JUMP_KEY__ = A; + fireEvent.click(screen.getByTestId('activity-stub-jump')); + + // Passing button is still the active filter — no silent reset. + expect(passingBtn.className).toMatch(/is-active/); + expect(document.getElementById(`proposal-row-${A}`)).not.toBeNull(); + }); +});