From 518e93acd646d275611abb6b2b06ae6fdb3ad6bf Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 21 Apr 2026 07:32:01 -0700 Subject: [PATCH 1/5] feat(gov): ops hero, activity card, confidence signals, metadata chips (PR 6c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third UX pass on the governance surface. Closes the loop on situational awareness: a signed-in voter can now see at a glance what they represent, what their vault has already done, whether their next vote will count, and which proposals deserve attention right now. - GovernanceOpsHero + lib/governanceOps: represent / need-vote / passing counts, progress bar, Jump-to-next-unvoted CTA. - GovernanceActivity: last-10 receipts card with jump-to-row, backed by the new GET /gov/receipts/recent endpoint (governanceService .fetchRecentReceipts). - Confidence signals: * verified-chip on ProposalRow, only when the reconciler has fresh (<5 min) confirmed receipts for this user on this proposal. * Pre-submit support-shift preview in the vote modal (lib/ governanceSupportShift): net AbsoluteYesCount delta from selected MNs vs. prior confirmed votes, with replacement count called out in the detail line. - Metadata chips (lib/governanceMeta): closing-soon / closing-urgent tiers off the superblock voting deadline, over-budget rank cut against the superblock budget, and margin-thin / margin-near within ±1.5% of the 10% pass line. Made-with: Cursor --- src/App.css | 485 ++++++++++++++++++++++ src/components/GovernanceActivity.js | 332 +++++++++++++++ src/components/GovernanceActivity.test.js | 208 ++++++++++ src/components/GovernanceOpsHero.js | 284 +++++++++++++ src/components/GovernanceOpsHero.test.js | 179 ++++++++ src/components/ProposalVoteModal.js | 41 ++ src/components/ProposalVoteModal.test.js | 110 +++++ src/lib/governanceMeta.js | 201 +++++++++ src/lib/governanceMeta.test.js | 225 ++++++++++ src/lib/governanceOps.js | 162 ++++++++ src/lib/governanceOps.test.js | 244 +++++++++++ src/lib/governanceService.js | 32 ++ src/lib/governanceService.test.js | 78 ++++ src/lib/governanceSupportShift.js | 158 +++++++ src/lib/governanceSupportShift.test.js | 180 ++++++++ src/pages/Governance.js | 253 ++++++++++- src/pages/Governance.test.js | 298 +++++++++++++ 17 files changed, 3468 insertions(+), 2 deletions(-) create mode 100644 src/components/GovernanceActivity.js create mode 100644 src/components/GovernanceActivity.test.js create mode 100644 src/components/GovernanceOpsHero.js create mode 100644 src/components/GovernanceOpsHero.test.js create mode 100644 src/lib/governanceMeta.js create mode 100644 src/lib/governanceMeta.test.js create mode 100644 src/lib/governanceOps.js create mode 100644 src/lib/governanceOps.test.js create mode 100644 src/lib/governanceSupportShift.js create mode 100644 src/lib/governanceSupportShift.test.js 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..d661116 --- /dev/null +++ b/src/lib/governanceMeta.js @@ -0,0 +1,201 @@ +// 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) { + running += Math.max(0, row.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; + 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..f46e882 --- /dev/null +++ b/src/lib/governanceMeta.test.js @@ -0,0 +1,225 @@ +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('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'); + }); +}); + +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..673f7dd 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,48 @@ 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, }) { const [feedback, setFeedback] = useState(''); const supportPercent = enabledCount @@ -93,8 +136,20 @@ function ProposalRow({ } } + const rowClasses = [ + 'proposal-row', + passing ? 'is-passing' : 'is-watch', + isHighlighted ? 'is-highlighted' : '', + ] + .filter(Boolean) + .join(' '); + return ( -
+
{statusLabel} @@ -109,6 +164,60 @@ 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 age = Date.now() - latestVerifiedAt; + if (!(age >= 0 && age < VERIFIED_FRESHNESS_MS)) return null; + const ago = verifiedAgo(latestVerifiedAt); + 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 +307,23 @@ 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); + const highlightTimerRef = useRef(null); const { error, loading, @@ -216,12 +338,56 @@ 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 only on the superblock deadline, + // so we derive it once per render and reuse the single object + // for every row. (Using Date.now() here is fine: the chip's + // rounding makes sub-minute drift invisible and any hard-timed + // behaviour — e.g. disabling the vote button at the deadline — + // lives server-side, not in this label.) + const closing = useMemo( + () => + closingChip({ + votingDeadline: superblockStats ? superblockStats.voting_deadline : 0, + }), + [superblockStats] + ); const visibleProposals = proposals.filter(function filterProposal(proposal) { const supportPercent = enabledCount ? (Number(proposal.AbsoluteYesCount || 0) / enabledCount) * 100 @@ -251,6 +417,50 @@ 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. We guard against + // unmount-after-timeout by clearing a ref on cleanup. + const jumpToProposal = useCallback((key) => { + if (typeof key !== 'string' || !key) return; + const domId = proposalRowDomId(key); + if (!domId) return; + if (typeof document !== 'undefined') { + const el = document.getElementById(domId); + if (el && typeof el.scrollIntoView === 'function') { + 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. + } + } + } + } + setHighlightKey(key); + if (highlightTimerRef.current) { + window.clearTimeout(highlightTimerRef.current); + } + highlightTimerRef.current = window.setTimeout(() => { + setHighlightKey(null); + highlightTimerRef.current = null; + }, JUMP_HIGHLIGHT_MS); + }, []); + + 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 +473,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 +560,22 @@ export default function Governance() {

) : null} + {isAuthenticated && stats ? ( +
+ + +
+ ) : null} @@ -436,6 +665,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..0f1d22f 100644 --- a/src/pages/Governance.test.js +++ b/src/pages/Governance.test.js @@ -404,3 +404,301 @@ 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); + }); +}); From 3d90e7b640cb80003dc2e469cbbee9521aaca340 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 21 Apr 2026 08:14:55 -0700 Subject: [PATCH 2/5] fix(gov): margin + jump guardrails (Codex round 1) - marginChip: use strict delta>0 at the 10% line so exactly-10% support reads as "Close to passing" instead of "Slim margin" (ProposalRow's pass check is `support > 10`, so contradictory copy at the boundary was confusing). - jumpToProposal: when the target proposal exists in the feed but is hidden by the current search/filter, clear filter+query first so the row is mounted before we scroll. Scrolling runs inside requestAnimationFrame so React has a chance to commit the filter reset before getElementById fires. Prevents the silent no-op CTA on filtered activity rows. Made-with: Cursor --- src/lib/governanceMeta.js | 7 +++- src/lib/governanceMeta.test.js | 14 +++++++ src/pages/Governance.js | 72 ++++++++++++++++++++++++++-------- 3 files changed, 76 insertions(+), 17 deletions(-) diff --git a/src/lib/governanceMeta.js b/src/lib/governanceMeta.js index d661116..6f5e733 100644 --- a/src/lib/governanceMeta.js +++ b/src/lib/governanceMeta.js @@ -190,7 +190,12 @@ export function marginChip({ proposal, enabledCount } = {}) { (Number(proposal.AbsoluteYesCount || 0) / enabled) * 100; const delta = support - PASSING_SUPPORT_PERCENT; if (Math.abs(delta) > MARGIN_WARNING_PERCENT) return null; - const above = delta >= 0; + // 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', diff --git a/src/lib/governanceMeta.test.js b/src/lib/governanceMeta.test.js index f46e882..7dd1673 100644 --- a/src/lib/governanceMeta.test.js +++ b/src/lib/governanceMeta.test.js @@ -214,6 +214,20 @@ describe('marginChip', () => { 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', () => { diff --git a/src/pages/Governance.js b/src/pages/Governance.js index 673f7dd..24116dc 100644 --- a/src/pages/Governance.js +++ b/src/pages/Governance.js @@ -418,30 +418,70 @@ export default function Governance() { }, []); // Smoothly scroll to a proposal by hash and briefly highlight - // it so the user's eye lands on the right row. We guard against - // unmount-after-timeout by clearing a ref on cleanup. + // 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; - if (typeof document !== 'undefined') { + + 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') { + 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({ 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. - } + 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); @@ -450,7 +490,7 @@ export default function Governance() { setHighlightKey(null); highlightTimerRef.current = null; }, JUMP_HIGHLIGHT_MS); - }, []); + }, [proposals, visibleProposals]); useEffect(() => { return () => { From 4e1c4cad7af4a7e32fd01639f73ca20da3226778 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 21 Apr 2026 08:22:52 -0700 Subject: [PATCH 3/5] test(gov): cover filter-aware jumpToProposal behaviour (Codex round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-raised the "silent no-op jump CTA under an active filter" concern against the filter-clearing fix. The jumpToProposal callback already resets filter+query when the target proposal exists in the feed but is hidden by the current filter; this commit adds explicit coverage so the behaviour is a pinned contract and future readers don't re-litigate the approach: - hidden target → filter state clears, row becomes mountable - visible target → filter state is preserved (no silent reset) The stubs for GovernanceOpsHero / GovernanceActivity expose the onJumpToProposal prop via a test-only button so these page-level tests don't need the real activity fetch. Made-with: Cursor --- src/pages/Governance.test.js | 141 +++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/src/pages/Governance.test.js b/src/pages/Governance.test.js index 0f1d22f..4f3abe7 100644 --- a/src/pages/Governance.test.js +++ b/src/pages/Governance.test.js @@ -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 @@ -702,3 +738,108 @@ describe('Governance page — proposal metadata chips', () => { expect(overBudgetKinds).toHaveLength(1); }); }); + +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(); + }); +}); From 98277e2c2286fe743134eabcf204086e8e50a301 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 21 Apr 2026 08:28:07 -0700 Subject: [PATCH 4/5] fix(gov): clamp non-finite payment_amount in over-budget computation (Codex round 2) A malformed payment_amount (non-numeric string from the feed) coerces to NaN, which then contaminates the running budget total. Once `running` is NaN every `running > ceiling` check returns false, so the over-budget chip silently disappears from downstream rows that should get it. Clamp non-finite amounts to 0 before adding. Biases toward "no warning" on an unknowable row rather than warning spuriously, while keeping cutline detection working for the rest of the list. Made-with: Cursor --- src/lib/governanceMeta.js | 11 ++++++++++- src/lib/governanceMeta.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/lib/governanceMeta.js b/src/lib/governanceMeta.js index 6f5e733..f24f9f3 100644 --- a/src/lib/governanceMeta.js +++ b/src/lib/governanceMeta.js @@ -157,7 +157,16 @@ export function computeOverBudgetMap({ let running = 0; for (const row of passing) { - running += Math.max(0, row.amount); + // 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', diff --git a/src/lib/governanceMeta.test.js b/src/lib/governanceMeta.test.js index 7dd1673..81a71ce 100644 --- a/src/lib/governanceMeta.test.js +++ b/src/lib/governanceMeta.test.js @@ -140,6 +140,31 @@ describe('computeOverBudgetMap', () => { 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({ From 44b995bca1dd7bca7da897f7657d625b7814b15a Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Tue, 21 Apr 2026 08:37:18 -0700 Subject: [PATCH 5/5] fix(gov): tick nowMs so time-sensitive chips refresh on long-lived sessions (Codex round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The closing-window chip (and the verified-on-chain freshness check) both depend on Date.now(), but the derivations were memoized only on the stats object. That means on a tab that stays open past the chip's boundary: - "closing-soon" (7d window) never escalated to "closing-urgent" at the 48h line; - the chip never disappeared once the deadline passed; - the verified-chip freshness window (5 min) could linger even after staleness. Fix: add a slow-ticking nowMs state at the page level (one update per minute), thread it into the closingChip memo and into ProposalRow so the verified pill uses the same clock. The tick interval is coarse on purpose — chip copy rounds to m/h/d so sub-minute drift is invisible, and 60 re-renders per hour on a quiet page is cheap. Any hard-timed behaviour (server-side deadline enforcement) is unaffected. Adds two tests that advance wall-clock + timers without touching the stats object: soon→urgent escalation, and chip vanishing after the deadline. Made-with: Cursor --- src/pages/Governance.js | 39 +++++++++++---- src/pages/Governance.test.js | 96 +++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/pages/Governance.js b/src/pages/Governance.js index 24116dc..4c237cd 100644 --- a/src/pages/Governance.js +++ b/src/pages/Governance.js @@ -91,6 +91,7 @@ function ProposalRow({ isHighlighted, summaryRow, metaChips, + nowMs, }) { const [feedback, setFeedback] = useState(''); const supportPercent = enabledCount @@ -181,9 +182,10 @@ function ProposalRow({ if (!Number.isFinite(latestVerifiedAt) || latestVerifiedAt <= 0) { return null; } - const age = Date.now() - latestVerifiedAt; + 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); + const ago = verifiedAgo(latestVerifiedAt, now); const verifiedWhen = new Date(latestVerifiedAt).toUTCString(); const tooltip = `${confirmed} of your ${ @@ -323,6 +325,20 @@ export default function Governance() { // 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, @@ -375,18 +391,22 @@ export default function Governance() { [proposals, enabledCount, superblockStats] ); - // Per-row closing chip depends only on the superblock deadline, - // so we derive it once per render and reuse the single object - // for every row. (Using Date.now() here is fine: the chip's - // rounding makes sub-minute drift invisible and any hard-timed - // behaviour — e.g. disabling the vote button at the deadline — - // lives server-side, not in this label.) + // 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] + [superblockStats, nowMs] ); const visibleProposals = proposals.filter(function filterProposal(proposal) { const supportPercent = enabledCount @@ -729,6 +749,7 @@ export default function Governance() { cohort={cohort} summaryRow={isAuthenticated ? summaryRow : null} metaChips={rowMetaChips} + nowMs={nowMs} isHighlighted={ typeof highlightKey === 'string' && highlightKey.toLowerCase() === hashKey diff --git a/src/pages/Governance.test.js b/src/pages/Governance.test.js index 4f3abe7..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 @@ -739,6 +739,100 @@ describe('Governance page — proposal metadata chips', () => { }); }); +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();