diff --git a/apps/web/src/components/SimulatedFeeDisplay.tsx b/apps/web/src/components/SimulatedFeeDisplay.tsx
index ca54d5a..ac332f3 100644
--- a/apps/web/src/components/SimulatedFeeDisplay.tsx
+++ b/apps/web/src/components/SimulatedFeeDisplay.tsx
@@ -4,6 +4,7 @@ import {
type SimulationResult,
type SimulationStatus,
formatFeeBreakdown,
+ 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/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/proposal-events/votes.ts b/apps/web/src/lib/proposal-events/votes.ts
index b9dbb0e..2f6f65a 100644
--- a/apps/web/src/lib/proposal-events/votes.ts
+++ b/apps/web/src/lib/proposal-events/votes.ts
@@ -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();