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