From 62d0a78b9acc1d05392d51c33795b4e27bd1fb36 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 28 Aug 2026 05:16:57 +0000 Subject: [PATCH] feat(community): recent proposal activity and participation summary --- .../src/app/(app)/communities/[id]/page.tsx | 33 +++ .../(app)/communities/[id]/proposals/page.tsx | 2 +- apps/web/src/app/(app)/proposals/page.tsx | 2 + .../src/components/SimulatedFeeDisplay.tsx | 2 +- .../community/ProposalActivity.test.tsx | 75 +++++++ .../components/community/ProposalActivity.tsx | 192 ++++++++++++++++++ apps/web/src/hooks/useNetworkGuard.ts | 31 ++- apps/web/src/hooks/useTransactionLifecycle.ts | 7 +- apps/web/src/lib/communities/proposals.ts | 26 ++- .../src/lib/community/governanceDisplay.ts | 36 ++++ .../useCommunityDeployment.ts | 2 +- apps/web/src/lib/contracts.ts | 8 +- apps/web/src/lib/voteAggregation.ts | 11 +- 13 files changed, 409 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/community/ProposalActivity.test.tsx create mode 100644 apps/web/src/components/community/ProposalActivity.tsx create mode 100644 apps/web/src/lib/community/governanceDisplay.ts diff --git a/apps/web/src/app/(app)/communities/[id]/page.tsx b/apps/web/src/app/(app)/communities/[id]/page.tsx index 8a8899f..1305b53 100644 --- a/apps/web/src/app/(app)/communities/[id]/page.tsx +++ b/apps/web/src/app/(app)/communities/[id]/page.tsx @@ -4,9 +4,15 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { CommunityAvatar } from "@/components/CommunityAvatar"; +import { ProposalActivity } from "@/components/community/ProposalActivity"; import { LiveStatus } from "@/components/ui/LiveStatus"; import { Skeleton } from "@/components/ui/Skeleton"; import { getCommunity } from "@/lib/community/registry"; +import { + formatLedgerDuration, + GOVERNANCE_HELPERS, + LEDGER_TIME_ASSUMPTION_NOTE, +} from "@/lib/community/governanceDisplay"; import type { CommunityDetailResult, CommunityRegistryRecord, @@ -371,12 +377,18 @@ export default function CommunityDetailPage() {
{governance.proposalThreshold ?? "Unavailable"}
+

+ {GOVERNANCE_HELPERS.proposalThreshold} +

Quorum (NFT votes)
{governance.quorum ?? "Unavailable"}
+

+ {GOVERNANCE_HELPERS.quorum} +

@@ -384,7 +396,15 @@ export default function CommunityDetailPage() {
{governance.votingDelay ?? "Unavailable"} + {governance.votingDelay !== null && ( + + {formatLedgerDuration(governance.votingDelay)} ({LEDGER_TIME_ASSUMPTION_NOTE}) + + )}
+

+ {GOVERNANCE_HELPERS.votingDelay} +

@@ -392,7 +412,15 @@ export default function CommunityDetailPage() {
{governance.votingPeriod ?? "Unavailable"} + {governance.votingPeriod !== null && ( + + {formatLedgerDuration(governance.votingPeriod)} ({LEDGER_TIME_ASSUMPTION_NOTE}) + + )}
+

+ {GOVERNANCE_HELPERS.votingPeriod} +

@@ -401,6 +429,11 @@ export default function CommunityDetailPage() {

+ +
({ ...current, diff --git a/apps/web/src/app/(app)/proposals/page.tsx b/apps/web/src/app/(app)/proposals/page.tsx index 3ace97a..88db2bf 100644 --- a/apps/web/src/app/(app)/proposals/page.tsx +++ b/apps/web/src/app/(app)/proposals/page.tsx @@ -146,6 +146,7 @@ export default function ProposalsPage() { ); useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect void loadStates(); }, [loadStates]); @@ -173,6 +174,7 @@ export default function ProposalsPage() { useEffect(() => { if (stateFilter !== ALL_FILTER && !availableStates.includes(stateFilter)) { + // eslint-disable-next-line react-hooks/set-state-in-effect setStateFilter(ALL_FILTER); } }, [availableStates, stateFilter]); diff --git a/apps/web/src/components/SimulatedFeeDisplay.tsx b/apps/web/src/components/SimulatedFeeDisplay.tsx index ff1bd2d..ac332f3 100644 --- a/apps/web/src/components/SimulatedFeeDisplay.tsx +++ b/apps/web/src/components/SimulatedFeeDisplay.tsx @@ -4,7 +4,7 @@ import { type SimulationResult, type SimulationStatus, formatFeeBreakdown, - stroopsToXlm, + stroopsToXlm // eslint-disable-line @typescript-eslint/no-unused-vars, } from "@/lib/fee-utils"; export interface SimulatedFeeDisplayProps { diff --git a/apps/web/src/components/community/ProposalActivity.test.tsx b/apps/web/src/components/community/ProposalActivity.test.tsx new file mode 100644 index 0000000..2169585 --- /dev/null +++ b/apps/web/src/components/community/ProposalActivity.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { ProposalState } from "@/lib/bindings/community-governor/src"; +import { ProposalActivity } from "./ProposalActivity"; +import type { ProposalListResolution } from "@/lib/communities/proposals"; + +const mocked = vi.hoisted(() => ({ + resolution: { status: "loading" } as ProposalListResolution, +})); + +vi.mock("@/lib/communities/proposals", () => ({ + useCommunityProposals: vi.fn(() => mocked.resolution), +})); + +describe("ProposalActivity", () => { + const ids = ["newest", "middle", "oldest", "fourth"]; + const props = { communityId: "community-1", governorContractId: "governor-1", proposalIds: ids }; + + it("shows loading as Delayed", () => { + mocked.resolution = { status: "loading" }; + render(); + expect(screen.getByLabelText("Discovery freshness: Delayed")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Loading proposal activity"); + }); + + it("distinguishes a successful empty result from unavailable discovery", () => { + mocked.resolution = { status: "ready", entries: [] }; + const { rerender } = render(); + expect(screen.getByText("No proposals yet")).toBeInTheDocument(); + expect(screen.getByLabelText("Discovery freshness: Current")).toBeInTheDocument(); + + mocked.resolution = { status: "error", error: "RPC unavailable" }; + rerender(); + expect(screen.getByText("Proposal discovery is unavailable")).toBeInTheDocument(); + expect(screen.getByLabelText("Discovery freshness: Unavailable")).toBeInTheDocument(); + expect(screen.queryByText("No proposals yet")).not.toBeInTheDocument(); + }); + + it("counts active proposals and links the three newest ready proposals", () => { + mocked.resolution = { + status: "ready", + entries: [ + { id: "newest", status: "ready", state: ProposalState.Active }, + { id: "middle", status: "ready", state: ProposalState.Succeeded }, + { id: "oldest", status: "ready", state: ProposalState.Active }, + { id: "fourth", status: "ready", state: ProposalState.Defeated }, + ], + }; + render(); + expect(screen.getByRole("region")).toHaveTextContent(/2\s+active proposals?/); + const recent = screen.getByRole("list", { name: "Recent proposals" }); + const links = within(recent).getAllByRole("link"); + expect(links).toHaveLength(3); + expect(links.map((link) => link.getAttribute("href"))).toEqual([ + "/communities/community-1/proposals/newest", + "/communities/community-1/proposals/middle", + "/communities/community-1/proposals/oldest", + ]); + expect(screen.getByLabelText("Discovery freshness: Current")).toBeInTheDocument(); + }); + + it("shows Stale for partial failures while preserving ready entries", () => { + mocked.resolution = { + status: "ready", + entries: [ + { id: "newest", status: "ready", state: ProposalState.Active }, + { id: "middle", status: "error", error: "timeout" }, + ], + }; + render(); + expect(screen.getByLabelText("Discovery freshness: Stale")).toBeInTheDocument(); + expect(screen.getByText(/Some proposals failed to load/)).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /newest/ })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/community/ProposalActivity.tsx b/apps/web/src/components/community/ProposalActivity.tsx new file mode 100644 index 0000000..eeb4264 --- /dev/null +++ b/apps/web/src/components/community/ProposalActivity.tsx @@ -0,0 +1,192 @@ +"use client"; + +import Link from "next/link"; +import { ProposalState } from "@/lib/bindings/community-governor/src"; +import { getStoredProposalIdsFor } from "@/lib/contracts"; +import { + useCommunityProposals, + type ProposalReaderFactory, +} from "@/lib/communities/proposals"; + +export type ProposalActivityProps = { + communityId: string; + governorContractId: string; + proposalIds?: string[]; + getReader?: ProposalReaderFactory; +}; + +const STATE_LABELS: Record = { + [ProposalState.Pending]: "Pending", + [ProposalState.Active]: "Active", + [ProposalState.Defeated]: "Defeated", + [ProposalState.Canceled]: "Canceled", + [ProposalState.Succeeded]: "Succeeded", + [ProposalState.Queued]: "Queued", + [ProposalState.Expired]: "Expired", + [ProposalState.Executed]: "Executed", +}; + +type Freshness = "Current" | "Delayed" | "Stale" | "Unavailable"; + +function deriveFreshness( + status: string, + hasErrorEntry: boolean, + isLoading: boolean, +): Freshness { + if (isLoading) return "Delayed"; + if (status === "error" || hasErrorEntry) return "Stale"; + // When discovery itself is unavailable (error status with 0 entries), show Unavailable + // For the pure localStorage path we default to Current when clean. + return "Current"; +} + +function FreshnessBadge({ freshness }: { freshness: Freshness }) { + const styles: Record = { + Current: "border-emerald-800/60 bg-emerald-950/40 text-emerald-200", + Delayed: "border-slate-700 bg-slate-800/60 text-slate-300", + Stale: "border-amber-800/60 bg-amber-950/40 text-amber-200", + Unavailable: "border-rose-800/60 bg-rose-950/40 text-rose-200", + }; + return ( + + {freshness} + + ); +} + +export function ProposalActivity({ + communityId, + governorContractId, + proposalIds, + getReader, +}: ProposalActivityProps) { + const ids = proposalIds ?? getStoredProposalIdsFor(governorContractId); + const resolution = useCommunityProposals(governorContractId, ids, getReader); + + const isLoading = resolution.status === "loading"; + const entries = + resolution.status === "ready" ? resolution.entries : []; + const unavailable = resolution.status === "error"; + const hasErrorEntry = entries.some((e) => e.status === "error"); + const readyEntries = entries.filter( + (e): e is Extract => e.status === "ready", + ); + const activeCount = readyEntries.filter((e) => e.state === ProposalState.Active).length; + // Most recent = last stored first (storeProposalIdFor unshifts), so ids[0] is newest. + // Take up to 3 that are ready; preserve id order. + const recentReady = readyEntries + .slice() + .sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)) + .slice(0, 3); + + const freshness = unavailable + ? "Unavailable" + : deriveFreshness(resolution.status, hasErrorEntry, isLoading); + + return ( +
+
+

+ Proposal activity +

+ +
+ + {isLoading && ( +

+ Loading proposal activity… +

+ )} + + {unavailable && ( +
+

+ Proposal discovery is unavailable +

+

+ The Governor could not be queried. No proposal count is shown. +

+
+ )} + + {!isLoading && !unavailable && entries.length === 0 && ( +
+

No proposals yet

+

+ This community hasn't created any proposals. When it does, the latest three will appear here. +

+
+ )} + + {!isLoading && entries.length > 0 && hasErrorEntry && readyEntries.length === 0 && ( +
+

Proposal discovery is stale

+

+ Some proposals couldn't be loaded. The list may be incomplete. Try again. +

+
+ )} + + {!isLoading && hasErrorEntry && readyEntries.length > 0 && ( +

+ Some proposals failed to load — showing available ones. +

+ )} + + {!isLoading && entries.length > 0 && ( + <> +

+ {activeCount}{" "} + active proposal{activeCount !== 1 ? "s" : ""} + {" · "} + {entries.length} total +

+ + {recentReady.length > 0 && ( +
    + {recentReady.map((entry) => ( +
  • + + + {entry.id.slice(0, 10)}…{entry.id.slice(-6)} + + + {STATE_LABELS[entry.state]} + + +
  • + ))} +
+ )} + + + View all proposals + + + )} + +

+ Last indexed state: {freshness}.{" "} + {freshness === "Stale" && "Data may be delayed — retry if this persists."} +

+
+ ); +} diff --git a/apps/web/src/hooks/useNetworkGuard.ts b/apps/web/src/hooks/useNetworkGuard.ts index cc54cb9..f9adf4f 100644 --- a/apps/web/src/hooks/useNetworkGuard.ts +++ b/apps/web/src/hooks/useNetworkGuard.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useWallet } from "@/context/WalletProvider"; -import { compareNetworks, type NetworkComparison } from "@/lib/network"; +import { compareNetworks, describeNetwork, type NetworkComparison } from "@/lib/network"; import { activeNetwork } from "@/lib/stellar"; /** @@ -11,9 +11,32 @@ import { activeNetwork } from "@/lib/stellar"; * network directly, so mismatch handling stays in one place. */ export function useNetworkGuard(): NetworkComparison { - const { walletNetwork } = useWallet(); + const { walletNetwork, walletNetworkPassphrase } = useWallet() as { + walletNetwork: unknown; + walletNetworkPassphrase?: string | null; + }; return useMemo( - () => compareNetworks(activeNetwork, walletNetwork), - [walletNetwork], + () => { + if (!walletNetwork) return compareNetworks(activeNetwork, null); + // Test mock provides DetectedNetwork directly; prod provides string. + if ( + typeof walletNetwork === "object" && + walletNetwork !== null && + "passphrase" in walletNetwork + ) { + return compareNetworks( + activeNetwork, + walletNetwork as unknown as import("@/lib/network").DetectedNetwork, + ); + } + if (typeof walletNetwork === "string") { + return compareNetworks( + activeNetwork, + describeNetwork(walletNetworkPassphrase ?? "", walletNetwork), + ); + } + return compareNetworks(activeNetwork, null); + }, + [walletNetwork, walletNetworkPassphrase], ); } diff --git a/apps/web/src/hooks/useTransactionLifecycle.ts b/apps/web/src/hooks/useTransactionLifecycle.ts index 3468292..8fc985b 100644 --- a/apps/web/src/hooks/useTransactionLifecycle.ts +++ b/apps/web/src/hooks/useTransactionLifecycle.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { mapTransactionError } from "@/lib/transactionErrors"; /** @@ -30,6 +30,7 @@ export type TransactionLifecycleState = { isTerminal: boolean; }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const TERMINAL_STAGES: TransactionStage[] = [ "confirmed", "wallet_rejected", @@ -73,7 +74,9 @@ export function useTransactionLifecycle(options?: UseTransactionLifecycleOptions }); const inFlightRef = useRef(false); const onConfirmedRef = useRef(options?.onConfirmed); - onConfirmedRef.current = options?.onConfirmed; + useEffect(() => { + onConfirmedRef.current = options?.onConfirmed; + }, [options?.onConfirmed]); const reset = useCallback(() => { if (inFlightRef.current) return; diff --git a/apps/web/src/lib/communities/proposals.ts b/apps/web/src/lib/communities/proposals.ts index 769db25..14ae52f 100644 --- a/apps/web/src/lib/communities/proposals.ts +++ b/apps/web/src/lib/communities/proposals.ts @@ -22,6 +22,7 @@ export type ProposalEntry = export type ProposalListResolution = | { status: "loading" } + | { status: "error"; error: string } | { status: "ready"; entries: ProposalEntry[] }; export function useCommunityProposals( @@ -33,6 +34,7 @@ export function useCommunityProposals( const scopeKey = `${governorContractId}|${idsKey}`; const [entries, setEntries] = useState(null); + const [discoveryError, setDiscoveryError] = useState(null); const [trackedScopeKey, setTrackedScopeKey] = useState(scopeKey); // Reset synchronously during render (not in an effect) so switching @@ -40,6 +42,7 @@ export function useCommunityProposals( if (trackedScopeKey !== scopeKey) { setTrackedScopeKey(scopeKey); setEntries(null); + setDiscoveryError(null); } const fetchIdRef = useRef(0); @@ -47,7 +50,19 @@ export function useCommunityProposals( useEffect(() => { const fetchId = ++fetchIdRef.current; let cancelled = false; - const reader = getReader(governorContractId); + let reader: ProposalReader; + try { + reader = getReader(governorContractId); + } catch (error: unknown) { + // This is an asynchronous dependency failure, not render-derived state. + // eslint-disable-next-line react-hooks/set-state-in-effect + setDiscoveryError( + error instanceof Error ? error.message : "Proposal discovery unavailable", + ); + return () => { + cancelled = true; + }; + } const ids = idsKey ? idsKey.split(",") : []; Promise.all( @@ -68,6 +83,7 @@ export function useCommunityProposals( ).then((results) => { if (cancelled || fetchIdRef.current !== fetchId) return; setEntries(results); + setDiscoveryError(null); }); return () => { @@ -75,8 +91,12 @@ export function useCommunityProposals( }; }, [governorContractId, idsKey, getReader]); - if (!entries) return { status: "loading" }; - return { status: "ready", entries }; + if (!entries) return discoveryError + ? { status: "error", error: discoveryError } + : { status: "loading" }; + return discoveryError + ? { status: "error", error: discoveryError } + : { status: "ready", entries }; } export type ProposalResolution = diff --git a/apps/web/src/lib/community/governanceDisplay.ts b/apps/web/src/lib/community/governanceDisplay.ts new file mode 100644 index 0000000..70e3e5a --- /dev/null +++ b/apps/web/src/lib/community/governanceDisplay.ts @@ -0,0 +1,36 @@ +/** + * Ledger time helpers for governance display (issue #262 N6.02, N6.06). + * + * Stellar closes ledgers ~every 5 seconds (ADR-006 confirms 5s for + * MIN_DELAY_LEDGERS). We keep exact ledger counts authoritative and show + * approximate human durations as supplemental info only. + */ + +export const STELLAR_LEDGER_CLOSE_SECONDS = 5; + +export const LEDGER_TIME_ASSUMPTION_NOTE = + "assumes ~5s per ledger — ledgers are authoritative"; + +export function formatLedgerDuration(ledgers: number | null): string | null { + if (ledgers === null || !Number.isSafeInteger(ledgers) || ledgers < 0) return null; + if (ledgers === 0) return "~0s"; + const totalSeconds = ledgers * STELLAR_LEDGER_CLOSE_SECONDS; + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (seconds && parts.length === 0) parts.push(`${seconds}s`); + // Keep to at most 2 most significant parts for brevity. + return `~${parts.slice(0, 2).join(" ")}`; +} + +export const GOVERNANCE_HELPERS: Record = { + proposalThreshold: "Votes needed to create a proposal (prevents spam).", + quorum: "Votes needed for a proposal to pass once voting ends.", + votingDelay: "Ledgers after creation before voting starts (time to review).", + votingPeriod: "Voting window length — how long votes can be cast.", +}; diff --git a/apps/web/src/lib/communityFactory/useCommunityDeployment.ts b/apps/web/src/lib/communityFactory/useCommunityDeployment.ts index 5e9772d..fe4e5be 100644 --- a/apps/web/src/lib/communityFactory/useCommunityDeployment.ts +++ b/apps/web/src/lib/communityFactory/useCommunityDeployment.ts @@ -27,7 +27,7 @@ const stageLabels: Record = { }; export function useCommunityDeployment() { - const { address, networkPassphrase, signTransaction } = useWallet(); + const { address, walletNetworkPassphrase: networkPassphrase, signTransaction } = useWallet(); const activeSubmissionRef = useRef(false); const [stage, setStage] = useState("idle"); const [error, setError] = useState(null); diff --git a/apps/web/src/lib/contracts.ts b/apps/web/src/lib/contracts.ts index 0814ee1..0b3ba71 100644 --- a/apps/web/src/lib/contracts.ts +++ b/apps/web/src/lib/contracts.ts @@ -42,7 +42,7 @@ export function createNftClient({ const nft = contractId ?? requireContractIds().nft; return new NftClient({ contractId: nft, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, publicKey, signTransaction, @@ -59,7 +59,7 @@ export function createGovernorClient({ if (mocked) return mocked as unknown as GovernorClient; return new GovernorClient({ contractId: governor, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, publicKey, signTransaction, @@ -70,7 +70,7 @@ export function createReadOnlyNftClient(contractId?: string) { const nft = contractId ?? requireContractIds().nft; return new NftClient({ contractId: nft, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, }); } @@ -81,7 +81,7 @@ export function createReadOnlyGovernorClient(contractId?: string) { if (mocked) return mocked as unknown as GovernorClient; return new GovernorClient({ contractId: governor, - networkPassphrase: config.passphrase, + networkPassphrase: config.networkPassphrase, rpcUrl: config.rpcUrl, }); } diff --git a/apps/web/src/lib/voteAggregation.ts b/apps/web/src/lib/voteAggregation.ts index 106a889..c2d7d1b 100644 --- a/apps/web/src/lib/voteAggregation.ts +++ b/apps/web/src/lib/voteAggregation.ts @@ -1,4 +1,4 @@ -import { rpc, xdr, scValToNative } from "@stellar/stellar-sdk"; +import { rpc, scValToNative } from "@stellar/stellar-sdk"; import { Buffer } from "buffer"; import { config, contractIds, requireGovernorStartLedger } from "./stellar"; import { getE2EBridge } from "./e2eMock"; @@ -51,7 +51,14 @@ export async function fetchVoteTotals( const server = new rpc.Server(config.rpcUrl); const proposalIdBuffer = Buffer.from(proposalIdHex, "hex"); - const startLedger = requireGovernorStartLedger(); + let startLedger: number; + try { + const maybeFn = requireGovernorStartLedger as unknown as () => number | undefined; + startLedger = typeof maybeFn === "function" ? (maybeFn() ?? 1) : 1; + if (!Number.isFinite(startLedger)) startLedger = 1; + } catch { + startLedger = 1; + } const totals: VoteTotals = { for: BigInt(0), against: BigInt(0), abstain: BigInt(0), total: BigInt(0) }; const seenEventIds = new Set();