From 0907648a571ccfcd620055d4f9121aef18514035 Mon Sep 17 00:00:00 2001
From: Silas Mantisa
Date: Thu, 22 Oct 2026 09:40:11 +0100
Subject: [PATCH 1/4] feat(governance): show timelock countdown and shared
status chips (#681)
---
.../governance/[id]/ProposalDetailClient.tsx | 16 +++-----
src/app/governance/archive/page.tsx | 5 ++-
src/components/ProposalStatusChip.tsx | 23 ++++++++++++
src/components/TimelockBanner.tsx | 37 +++++++++++++++++++
4 files changed, 68 insertions(+), 13 deletions(-)
create mode 100644 src/components/ProposalStatusChip.tsx
create mode 100644 src/components/TimelockBanner.tsx
diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx
index d29b207..7803d47 100644
--- a/src/app/governance/[id]/ProposalDetailClient.tsx
+++ b/src/app/governance/[id]/ProposalDetailClient.tsx
@@ -14,6 +14,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { Nav } from "@/components/Nav";
import { ConnectWalletButton } from "@/components/ConnectWalletButton";
+import { ProposalStatusChip } from "@/components/ProposalStatusChip";
+import { TimelockBanner } from "@/components/TimelockBanner";
import { VotePanel } from "@/components/VotePanel";
import { VotingPowerPanel } from "@/components/VotingPowerPanel";
import { useWalletStore } from "@/store/wallet";
@@ -322,17 +324,7 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
{proposal.category}
-
- {proposal.status}
-
+
{proposal.title}
@@ -342,6 +334,8 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
+ {proposal.status === "queued" && }
+
{/* ── Main column ──────────────────────────────────────────────── */}
diff --git a/src/app/governance/archive/page.tsx b/src/app/governance/archive/page.tsx
index d911996..bfa2a7f 100644
--- a/src/app/governance/archive/page.tsx
+++ b/src/app/governance/archive/page.tsx
@@ -3,9 +3,10 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { governanceStore } from '@/lib/governanceStore';
import { isValidStellarAddress } from '@/lib/stellarAddress';
+import { ProposalStatusChip } from '@/components/ProposalStatusChip';
import { toCsv } from '@/lib/csv';
-const STATUSES = ['all', 'passed', 'rejected', 'expired', 'executed'] as const;
+const STATUSES = ['all', 'active', 'queued', 'passed', 'rejected', 'expired', 'executed'] as const;
type Status = (typeof STATUSES)[number];
const PAGE_SIZE = 10;
@@ -179,7 +180,7 @@ export default async function GovernanceArchivePage({
{p.title}
-
{p.status}
+
diff --git a/src/components/ProposalStatusChip.tsx b/src/components/ProposalStatusChip.tsx
new file mode 100644
index 0000000..ff78a15
--- /dev/null
+++ b/src/components/ProposalStatusChip.tsx
@@ -0,0 +1,23 @@
+import type { GovernanceProposalStatus } from "@/lib/governanceStore";
+
+/** Single source of truth for the label/color of every proposal status. */
+export const PROPOSAL_STATUS_CHIP: Record
= {
+ active: { label: "Active", className: "bg-vx-sage-bg text-vx-sage border border-vx-sage/30" },
+ passed: { label: "Passed", className: "bg-blue-500/10 text-blue-400 border border-blue-500/30" },
+ rejected: { label: "Rejected", className: "bg-red-500/10 text-red-400 border border-red-500/30" },
+ queued: { label: "Queued", className: "bg-amber-500/10 text-amber-400 border border-amber-500/30" },
+ executed: { label: "Executed", className: "bg-emerald-500/10 text-emerald-400 border border-emerald-500/30" },
+ expired: { label: "Expired", className: "bg-vx-surface text-vx-muted border border-vx-border" },
+};
+
+export function ProposalStatusChip({ status }: { status: GovernanceProposalStatus }) {
+ const chip = PROPOSAL_STATUS_CHIP[status];
+ return (
+
+ {chip.label}
+
+ );
+}
diff --git a/src/components/TimelockBanner.tsx b/src/components/TimelockBanner.tsx
new file mode 100644
index 0000000..ab62d1d
--- /dev/null
+++ b/src/components/TimelockBanner.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useLiveRelativeTime } from "@/hooks/useLiveRelativeTime";
+
+function formatRemaining(ms: number): string {
+ const totalMinutes = Math.floor(ms / 60_000);
+ const days = Math.floor(totalMinutes / 1440);
+ const hours = Math.floor((totalMinutes % 1440) / 60);
+ const minutes = totalMinutes % 60;
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`;
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ return `${minutes}m`;
+}
+
+/** Read-only timelock countdown for a queued proposal. */
+export function TimelockBanner({ timelockEndsAt }: { timelockEndsAt?: string }) {
+ const now = useLiveRelativeTime(15_000);
+ const endsAt = timelockEndsAt ? Date.parse(timelockEndsAt) : NaN;
+
+ let message: string;
+ if (Number.isNaN(endsAt)) {
+ message = "Queued in timelock. The execution time is not available yet.";
+ } else if (endsAt <= now) {
+ message = "Timelock ended. This proposal is ready to execute.";
+ } else {
+ message = `In timelock. Executable in ${formatRemaining(endsAt - now)} (${new Date(endsAt).toUTCString()}).`;
+ }
+
+ return (
+
+ {message}
+
+ );
+}
From f72f3af63f45f3cf53b37317aef39c264a0ea661 Mon Sep 17 00:00:00 2001
From: Silas Mantisa
Date: Thu, 22 Oct 2026 09:40:23 +0100
Subject: [PATCH 2/4] feat(governance): cross-link delegate directory and voter
history (#682)
---
src/app/governance/delegates/page.tsx | 53 +++++++--------------
src/app/governance/voter/[address]/page.tsx | 9 ++++
src/lib/delegateDirectory.ts | 35 ++++++++++++++
3 files changed, 61 insertions(+), 36 deletions(-)
create mode 100644 src/lib/delegateDirectory.ts
diff --git a/src/app/governance/delegates/page.tsx b/src/app/governance/delegates/page.tsx
index 76a1db7..4316389 100644
--- a/src/app/governance/delegates/page.tsx
+++ b/src/app/governance/delegates/page.tsx
@@ -1,51 +1,17 @@
'use client';
+import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useVotingPower } from '@/hooks/useVotingPower';
import { useDelegation } from '@/hooks/useDelegation';
import { sanitizeText } from '@/lib/textSafety';
import { isSameStellarAddress, isValidStellarAddress } from '@/lib/stellarAddress';
+import { MOCK_DELEGATES, type DelegateProfile } from '@/lib/delegateDirectory';
import { secureLogger } from '@/lib/secureLogging';
-interface DelegateProfile {
- address: string;
- name: string;
- bio: string;
- votingPower: number;
- participationRate: number;
- proposalsVoted: number;
-}
-
type SortKey = 'votingPower' | 'participationRate' | 'proposalsVoted';
type FlowStage = 'idle' | 'review' | 'simulate' | 'sign' | 'pending' | 'done';
-const MOCK_DELEGATES: DelegateProfile[] = [
- {
- address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
- name: 'Aurora Governance',
- bio: 'Active voter focused on protocol upgrades and treasury stewardship.',
- votingPower: 1250000,
- participationRate: 0.94,
- proposalsVoted: 87,
- },
- {
- address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF',
- name: 'Nova Collective',
- bio: 'Community-run delegate prioritising developer tooling and grants.',
- votingPower: 860000,
- participationRate: 0.81,
- proposalsVoted: 64,
- },
- {
- address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCWHF',
- name: 'Meridian Labs',
- bio: 'Security-first delegate reviewing every proposal before voting.',
- votingPower: 540000,
- participationRate: 0.72,
- proposalsVoted: 51,
- },
-];
-
function formatPower(value: number): string {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value);
}
@@ -96,6 +62,13 @@ export default function DelegatesPage() {
setConfirmedAddress('');
}, []);
+ // Deep link from the voter-history page: /governance/delegates?delegate=G...
+ useEffect(() => {
+ const addr = new URLSearchParams(window.location.search).get('delegate');
+ const match = MOCK_DELEGATES.find((d) => isSameStellarAddress(d.address, addr ?? ''));
+ if (match) setSelected(match);
+ }, []);
+
useEffect(() => {
if (stage === 'done') {
void refreshVotingPower();
@@ -234,6 +207,13 @@ export default function DelegatesPage() {
{sanitizeText(d.name)}
{d.address}
+
+
+ View voting history
+
+
{sanitizeText(d.bio)}
diff --git a/src/app/governance/voter/[address]/page.tsx b/src/app/governance/voter/[address]/page.tsx
index 8d39374..a89e45d 100644
--- a/src/app/governance/voter/[address]/page.tsx
+++ b/src/app/governance/voter/[address]/page.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link';
import { notFound } from 'next/navigation';
import { StrKey } from '@stellar/stellar-sdk';
import { governanceStore } from '@/lib/governanceStore';
+import { MOCK_DELEGATES } from '@/lib/delegateDirectory';
import { sanitizeText } from '@/lib/textSafety';
const WINDOWS = [30, 90, 365] as const;
@@ -84,6 +85,14 @@ export default async function VoterHistoryPage({ params, searchParams }: VoterPa
Voter history
{address}
+ {MOCK_DELEGATES.some((d) => d.address === address) && (
+
+ Delegate to this address
+
+ )}
diff --git a/src/lib/delegateDirectory.ts b/src/lib/delegateDirectory.ts
new file mode 100644
index 0000000..542a77f
--- /dev/null
+++ b/src/lib/delegateDirectory.ts
@@ -0,0 +1,35 @@
+export interface DelegateProfile {
+ address: string;
+ name: string;
+ bio: string;
+ votingPower: number;
+ participationRate: number;
+ proposalsVoted: number;
+}
+
+export const MOCK_DELEGATES: DelegateProfile[] = [
+ {
+ address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
+ name: 'Aurora Governance',
+ bio: 'Active voter focused on protocol upgrades and treasury stewardship.',
+ votingPower: 1250000,
+ participationRate: 0.94,
+ proposalsVoted: 87,
+ },
+ {
+ address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF',
+ name: 'Nova Collective',
+ bio: 'Community-run delegate prioritising developer tooling and grants.',
+ votingPower: 860000,
+ participationRate: 0.81,
+ proposalsVoted: 64,
+ },
+ {
+ address: 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCWHF',
+ name: 'Meridian Labs',
+ bio: 'Security-first delegate reviewing every proposal before voting.',
+ votingPower: 540000,
+ participationRate: 0.72,
+ proposalsVoted: 51,
+ },
+];
From 553e65608d75b14a7d324d26e343506c2bf64398 Mon Sep 17 00:00:00 2001
From: Silas Mantisa
Date: Thu, 22 Oct 2026 09:40:55 +0100
Subject: [PATCH 3/4] feat(governance): proposal deadline and status
notification subscriptions (#683)
---
.../governance/[id]/ProposalDetailClient.tsx | 20 ++++
src/hooks/useProposalSubscriptions.ts | 93 +++++++++++++++++++
src/lib/notifications/proposalReminders.ts | 59 ++++++++++++
src/lib/storage.ts | 7 ++
4 files changed, 179 insertions(+)
create mode 100644 src/hooks/useProposalSubscriptions.ts
create mode 100644 src/lib/notifications/proposalReminders.ts
diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx
index 7803d47..ee71016 100644
--- a/src/app/governance/[id]/ProposalDetailClient.tsx
+++ b/src/app/governance/[id]/ProposalDetailClient.tsx
@@ -16,6 +16,7 @@ import { Nav } from "@/components/Nav";
import { ConnectWalletButton } from "@/components/ConnectWalletButton";
import { ProposalStatusChip } from "@/components/ProposalStatusChip";
import { TimelockBanner } from "@/components/TimelockBanner";
+import { useProposalSubscriptions } from "@/hooks/useProposalSubscriptions";
import { VotePanel } from "@/components/VotePanel";
import { VotingPowerPanel } from "@/components/VotingPowerPanel";
import { useWalletStore } from "@/store/wallet";
@@ -238,6 +239,8 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
}
};
+ const { isSubscribed, toggle: toggleSubscription, reminders } = useProposalSubscriptions(proposal, userAddress ?? null);
+
const { power } = useVotingPower(userAddress, proposalId);
const votingPower = power?.total ?? 0;
@@ -334,6 +337,23 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
+
+
+ {reminders.map((r, i) => (
+
+ {r.message}
+
+ ))}
+
+
{proposal.status === "queued" && }
diff --git a/src/hooks/useProposalSubscriptions.ts b/src/hooks/useProposalSubscriptions.ts
new file mode 100644
index 0000000..4200c67
--- /dev/null
+++ b/src/hooks/useProposalSubscriptions.ts
@@ -0,0 +1,93 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { STORAGE_KEYS, onStorageChange, storage } from "@/lib/storage";
+import type { GovernanceProposal } from "@/lib/governanceStore";
+import {
+ DEFAULT_REMINDER_WINDOW_HOURS,
+ collectProposalEvents,
+ type ProposalEvent,
+ type ProposalSubscription,
+} from "@/lib/notifications/proposalReminders";
+import { decideChannels } from "@/lib/notifications/dispatcher";
+import { useNotificationPreferences } from "@/store/notificationPreferences";
+
+const KEY = STORAGE_KEYS.proposalSubscriptions.key;
+const CHECK_INTERVAL_MS = 60_000;
+
+export function readSubscriptions(): ProposalSubscription[] {
+ const parsed = storage.getJSON
(KEY);
+ return Array.isArray(parsed) ? parsed : [];
+}
+
+function writeSubscriptions(subs: ProposalSubscription[]): void {
+ if (subs.length === 0) storage.removeItem(KEY);
+ else storage.setJSON(KEY, subs);
+}
+
+/**
+ * Subscribe/unsubscribe to a proposal and surface due reminders while the tab
+ * is open. Quiet hours are honoured via the dispatcher's `decideChannels`.
+ */
+export function useProposalSubscriptions(proposal: GovernanceProposal | null | undefined, address: string | null) {
+ const [subs, setSubs] = useState([]);
+ const [reminders, setReminders] = useState([]);
+ const prefs = useNotificationPreferences();
+
+ useEffect(() => {
+ setSubs(readSubscriptions());
+ return onStorageChange(() => setSubs(readSubscriptions()));
+ }, []);
+
+ const proposalId = proposal?.id;
+ const isSubscribed = !!proposalId && !!address && subs.some((s) => s.proposalId === proposalId && s.address === address);
+
+ const toggle = useCallback(() => {
+ if (!proposal || !address) return;
+ const current = readSubscriptions();
+ const exists = current.some((s) => s.proposalId === proposal.id && s.address === address);
+ const next = exists
+ ? current.filter((s) => !(s.proposalId === proposal.id && s.address === address))
+ : [
+ ...current,
+ { proposalId: proposal.id, address, lastStatus: proposal.status, windowHours: DEFAULT_REMINDER_WINDOW_HOURS },
+ ];
+ writeSubscriptions(next);
+ setSubs(next);
+ if (exists) setReminders([]);
+ }, [proposal, address]);
+
+ useEffect(() => {
+ if (!proposal || !address || !isSubscribed) return;
+ const check = () => {
+ const now = Date.now();
+ const current = readSubscriptions();
+ const sub = current.find((s) => s.proposalId === proposal.id && s.address === address);
+ if (!sub) return;
+ const events = collectProposalEvents(sub, proposal, now);
+ if (events.length === 0) return;
+ const decision = decideChannels({
+ prefs,
+ status: "accepted",
+ visible: true,
+ isLeader: true,
+ permission: "unsupported",
+ now: new Date(now),
+ });
+ writeSubscriptions(
+ current.map((s) =>
+ s === sub
+ ? { ...s, lastStatus: proposal.status, deadlineNotified: s.deadlineNotified || events.some((e) => e.kind === "deadline") }
+ : s,
+ ),
+ );
+ // Toast channel disabled means the user opted out of in-app reminders.
+ if (decision.toast) setReminders((prev) => [...prev, ...events]);
+ };
+ check();
+ const timer = setInterval(check, CHECK_INTERVAL_MS);
+ return () => clearInterval(timer);
+ }, [proposal, address, isSubscribed, prefs]);
+
+ return { isSubscribed, toggle, reminders };
+}
diff --git a/src/lib/notifications/proposalReminders.ts b/src/lib/notifications/proposalReminders.ts
new file mode 100644
index 0000000..6df2a71
--- /dev/null
+++ b/src/lib/notifications/proposalReminders.ts
@@ -0,0 +1,59 @@
+import type { FeedItem } from "@/lib/types";
+import type { GovernanceProposal } from "@/lib/governanceStore";
+
+export type ProposalSubscription = {
+ proposalId: string;
+ address: string;
+ /** Status seen at subscribe/last check; a change triggers a transition event. */
+ lastStatus: GovernanceProposal["status"];
+ /** Reminder window before the deadline, in hours. */
+ windowHours: number;
+ /** Set once the deadline reminder fired so it is not repeated. */
+ deadlineNotified?: boolean;
+};
+
+export const DEFAULT_REMINDER_WINDOW_HOURS = 24;
+
+export type ProposalEvent = { kind: "deadline" | "status"; item: FeedItem; message: string };
+
+/** True when the deadline is in the future and within `windowHours`. */
+export function isWithinDeadlineWindow(deadline: string, windowHours: number, now: number): boolean {
+ const ends = Date.parse(deadline);
+ if (Number.isNaN(ends)) return false;
+ const remaining = ends - now;
+ return remaining > 0 && remaining <= windowHours * 3_600_000;
+}
+
+/** Builds FeedItem-shaped payloads that `decideChannels` can consume. */
+export function collectProposalEvents(
+ sub: ProposalSubscription,
+ proposal: GovernanceProposal,
+ now: number,
+): ProposalEvent[] {
+ const events: ProposalEvent[] = [];
+ const base = {
+ id: `${proposal.id}`,
+ srcAmount: "",
+ srcToken: proposal.title,
+ dstToken: "",
+ };
+ if (proposal.status !== sub.lastStatus) {
+ events.push({
+ kind: "status",
+ item: { ...base, status: "accepted" } as unknown as FeedItem,
+ message: `${proposal.id} is now ${proposal.status}`,
+ });
+ }
+ if (
+ !sub.deadlineNotified &&
+ proposal.status === "active" &&
+ isWithinDeadlineWindow(proposal.deadline, sub.windowHours, now)
+ ) {
+ events.push({
+ kind: "deadline",
+ item: { ...base, status: "accepted" } as unknown as FeedItem,
+ message: `Voting on ${proposal.id} closes ${new Date(proposal.deadline).toUTCString()}`,
+ });
+ }
+ return events;
+}
diff --git a/src/lib/storage.ts b/src/lib/storage.ts
index 2697bb3..eb28b29 100644
--- a/src/lib/storage.ts
+++ b/src/lib/storage.ts
@@ -60,6 +60,13 @@ export const STORAGE_KEYS = {
retentionMs: 90 * DAY,
owner: "src/lib/knownAddresses.ts",
},
+ proposalSubscriptions: {
+ key: "vortex:proposalSubscriptions",
+ purpose: "privacy.purpose.proposalSubscriptions",
+ sensitivity: "personal",
+ retentionMs: 90 * DAY,
+ owner: "src/hooks/useProposalSubscriptions.ts",
+ },
columnVisibility: {
key: "vortex:columns:",
prefix: true,
From 4ebf66db14288cfda7db16c6d54444fe2e9f9f04 Mon Sep 17 00:00:00 2001
From: Silas Mantisa
Date: Thu, 22 Oct 2026 09:41:15 +0100
Subject: [PATCH 4/4] feat(governance): side-by-side proposal comparison view
(#684)
---
.../governance/[id]/ProposalDetailClient.tsx | 6 +
.../governance/[id]/ProposalImpactPreview.tsx | 2 +-
src/app/governance/archive/page.tsx | 20 ++++
src/app/governance/compare/page.tsx | 113 ++++++++++++++++++
4 files changed, 140 insertions(+), 1 deletion(-)
create mode 100644 src/app/governance/compare/page.tsx
diff --git a/src/app/governance/[id]/ProposalDetailClient.tsx b/src/app/governance/[id]/ProposalDetailClient.tsx
index ee71016..8fd5376 100644
--- a/src/app/governance/[id]/ProposalDetailClient.tsx
+++ b/src/app/governance/[id]/ProposalDetailClient.tsx
@@ -347,6 +347,12 @@ export default function ProposalDetailClient({ proposalId }: { proposalId: strin
>
{isSubscribed ? "Notifications on" : "Notify me"}
+
+ Compare
+
{reminders.map((r, i) => (
{r.message}
diff --git a/src/app/governance/[id]/ProposalImpactPreview.tsx b/src/app/governance/[id]/ProposalImpactPreview.tsx
index 1b4f882..cd54767 100644
--- a/src/app/governance/[id]/ProposalImpactPreview.tsx
+++ b/src/app/governance/[id]/ProposalImpactPreview.tsx
@@ -29,7 +29,7 @@ export function ProposalImpactPreview({ proposal }: { proposal: GovernancePropos
);
}
-function BondImpact({ currentValue, proposedValue }: { currentValue: number; proposedValue: number }) {
+export function BondImpact({ currentValue, proposedValue }: { currentValue: number; proposedValue: number }) {
const { t } = useTranslation();
const { solvers, isLoading, error } = useSolvers();
diff --git a/src/app/governance/archive/page.tsx b/src/app/governance/archive/page.tsx
index bfa2a7f..2d7d0a5 100644
--- a/src/app/governance/archive/page.tsx
+++ b/src/app/governance/archive/page.tsx
@@ -168,6 +168,26 @@ export default async function GovernanceArchivePage({
+ {rows.length >= 2 && (
+
+ )}
+
{rows.length === 0 ? (
No proposals match the selected filters.
diff --git a/src/app/governance/compare/page.tsx b/src/app/governance/compare/page.tsx
new file mode 100644
index 0000000..639973b
--- /dev/null
+++ b/src/app/governance/compare/page.tsx
@@ -0,0 +1,113 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getGovernanceProposalById, getGovernanceProposals } from "@/lib/governanceStore";
+import type { GovernanceProposal } from "@/lib/governanceStore";
+import { ProposalStatusChip } from "@/components/ProposalStatusChip";
+import { BondImpact } from "../[id]/ProposalImpactPreview";
+import { sanitizeText } from "@/lib/textSafety";
+
+interface ComparePageProps {
+ searchParams?: { a?: string; b?: string };
+}
+
+function ProposalColumn({ proposal, showImpact }: { proposal: GovernanceProposal; showImpact: boolean }) {
+ const change = proposal.parameterChange;
+ return (
+
+
+ {sanitizeText(proposal.title)}
+
+ - Category
+ - {proposal.category}
+ - Votes for
+ - {proposal.votesFor.toLocaleString()}
+ - Votes against
+ - {proposal.votesAgainst.toLocaleString()}
+ - Votes abstain
+ - {(proposal.votesAbstain ?? 0).toLocaleString()}
+ - Parameter change
+ -
+ {change ? `${change.parameter}: ${change.currentValue} → ${change.proposedValue}` : "None"}
+
+
+ {showImpact && change && (
+
+
Projected solver impact
+
+
+ )}
+
+ );
+}
+
+export default function GovernanceComparePage({ searchParams }: ComparePageProps) {
+ const aId = searchParams?.a;
+ const bId = searchParams?.b;
+
+ const a = aId ? getGovernanceProposalById(aId) : undefined;
+ if (aId && !a) notFound();
+ const b = bId ? getGovernanceProposalById(bId) : undefined;
+ if (bId && !b) notFound();
+
+ if (!a || !b) {
+ const proposals = getGovernanceProposals();
+ return (
+
+ Compare proposals
+
+
+ );
+ }
+
+ const sameParam =
+ a.id !== b.id &&
+ a.parameterChange?.parameter === "minSolverBondUsd" &&
+ b.parameterChange?.parameter === "minSolverBondUsd";
+
+ return (
+
+
+ ← Back to Proposals
+
+
+ Compare {a.id} and {b.id}
+
+ {a.id === b.id && (
+
+ Both selections are the same proposal.
+
+ )}
+
+ {!sameParam && (
+
+ An impact comparison is not available: the proposals do not share a comparable parameter change, so only
+ tallies and metadata are shown.
+
+ )}
+
+ );
+}