From 5f8b298660c2c40263904328d4a4f00ccb6f5516 Mon Sep 17 00:00:00 2001 From: Francisco Campos Date: Wed, 26 Aug 2026 11:10:02 -0600 Subject: [PATCH] feat(web): show proposal discovery freshness and indexing limitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement PRD §5.1 freshness states (Current, Delayed, Stale, Unavailable) derived from RPC event scan metadata. Users can now distinguish complete history from incomplete responses on community and global proposal lists. - Add evaluateDiscoveryFreshness() pure function with ledger gap thresholds - Track latestLedger, lastEventLedger, and per-page errors in useProposalDiscovery - Add DiscoveryFreshnessBanner component with retry and docs link - Wire banner into /communities/[id]/proposals and /proposals pages - Unit tests cover all 4 states and malformed RPC metadata edge cases Closes #264 --- .../(app)/communities/[id]/proposals/page.tsx | 18 +- apps/web/src/app/(app)/proposals/page.tsx | 9 + .../components/DiscoveryFreshnessBanner.tsx | 84 +++++ apps/web/src/hooks/useProposalDiscovery.ts | 62 +++- apps/web/src/lib/proposal/freshness.test.ts | 289 ++++++++++++++++++ apps/web/src/lib/proposal/freshness.ts | 156 ++++++++++ apps/web/src/lib/proposal/index.ts | 7 + apps/web/src/test/proposal-discovery.test.tsx | 5 + 8 files changed, 626 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/DiscoveryFreshnessBanner.tsx create mode 100644 apps/web/src/lib/proposal/freshness.test.ts create mode 100644 apps/web/src/lib/proposal/freshness.ts diff --git a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx index d9efea5..5cba042 100644 --- a/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx +++ b/apps/web/src/app/(app)/communities/[id]/proposals/page.tsx @@ -5,6 +5,7 @@ import { Buffer } from "buffer"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ProposalSummaryCard } from "@/components/ProposalSummaryCard"; +import { DiscoveryFreshnessBanner } from "@/components/DiscoveryFreshnessBanner"; import { LiveStatus } from "@/components/ui/LiveStatus"; import { Skeleton } from "@/components/ui/Skeleton"; import { useProposalDiscovery } from "@/hooks/useProposalDiscovery"; @@ -22,8 +23,14 @@ const ALL_STATES = "all"; function ScopedProposalHistory({ community }: { community: CommunityView }) { const governorContract = community.record.governorContract; - const { proposals: discovered, loading, error, empty, refresh } = - useProposalDiscovery(governorContract); + const { + proposals: discovered, + loading, + error, + empty, + freshness, + refresh, + } = useProposalDiscovery(governorContract); const proposals = useMemo( () => Array.from( @@ -181,6 +188,13 @@ function ScopedProposalHistory({ community }: { community: CommunityView }) { )} + {!loading && proposals.length > 0 && ( + void refresh()} + /> + )} + {!loading && !error && empty && ( This community has no public proposals yet. diff --git a/apps/web/src/app/(app)/proposals/page.tsx b/apps/web/src/app/(app)/proposals/page.tsx index 3ace97a..537779f 100644 --- a/apps/web/src/app/(app)/proposals/page.tsx +++ b/apps/web/src/app/(app)/proposals/page.tsx @@ -13,6 +13,7 @@ import { import { contractIds } from "@/lib/stellar"; import { Skeleton } from "@/components/ui/Skeleton"; import { ProposalSummaryCard } from "@/components/ProposalSummaryCard"; +import { DiscoveryFreshnessBanner } from "@/components/DiscoveryFreshnessBanner"; import { truncateEnd } from "@/lib/truncate"; import { LiveStatus } from "@/components/ui/LiveStatus"; import { TransactionLifecycleStatus } from "@/components/TransactionLifecycleStatus"; @@ -46,6 +47,7 @@ export default function ProposalsPage() { loading, error, empty, + freshness, refresh, } = useProposalDiscovery(); @@ -440,6 +442,13 @@ export default function ProposalsPage() { )} + {!loading && uniqueProposalIds.length > 0 && ( + void refresh()} + /> + )} + {!loading && !error && empty && ( No public proposals have been discovered yet. diff --git a/apps/web/src/components/DiscoveryFreshnessBanner.tsx b/apps/web/src/components/DiscoveryFreshnessBanner.tsx new file mode 100644 index 0000000..e549173 --- /dev/null +++ b/apps/web/src/components/DiscoveryFreshnessBanner.tsx @@ -0,0 +1,84 @@ +"use client"; + +import type { FreshnessResult } from "@/lib/proposal/freshness"; + +const STATE_STYLES: Record< + FreshnessResult["state"], + { className: string; icon: string } +> = { + current: { + className: "border-emerald-800/60 bg-emerald-950/40 text-emerald-200", + icon: "\u2713", + }, + delayed: { + className: "border-amber-800/60 bg-amber-950/40 text-amber-200", + icon: "\u25B2", + }, + stale: { + className: "border-amber-800/70 bg-amber-950/50 text-amber-200", + icon: "\u25B2", + }, + unavailable: { + className: "border-rose-800/70 bg-rose-950/40 text-rose-200", + icon: "\u2717", + }, +}; + +const EXPLANATION_URL = + "https://github.com/stolla-labs/stolla/blob/main/docs/community-proposal-indexing.md#finality-freshness-and-caching"; + +export type DiscoveryFreshnessBannerProps = { + freshness: FreshnessResult; + /** When provided, render a retry button that calls this callback. */ + onRetry?: () => void; + isRetrying?: boolean; +}; + +/** + * Banner indicating the freshness of proposal discovery results. + * + * Renders nothing for the `current` state — users don't need to know + * that everything is fine. All other states show a banner with an + * explanation, a link to documentation, and an optional retry action. + */ +export function DiscoveryFreshnessBanner({ + freshness, + onRetry, + isRetrying = false, +}: DiscoveryFreshnessBannerProps) { + if (!freshness || freshness.state === "current") return null; + + const { className, icon } = STATE_STYLES[freshness.state]; + + return ( +
+

+ + {freshness.explanation} +

+
+ + Learn about proposal history limits + + {onRetry && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/hooks/useProposalDiscovery.ts b/apps/web/src/hooks/useProposalDiscovery.ts index fee0ac5..4b2ef27 100644 --- a/apps/web/src/hooks/useProposalDiscovery.ts +++ b/apps/web/src/hooks/useProposalDiscovery.ts @@ -1,10 +1,14 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Server as RpcServer } from "@stellar/stellar-sdk/rpc"; import type { Api } from "@stellar/stellar-sdk/rpc"; import { config, requireContractIds, requireGovernorStartLedger } from "@/lib/stellar"; import { decodeProposalEvent } from "@/lib/proposalEvents"; +import { + evaluateDiscoveryFreshness, + type FreshnessResult, +} from "@/lib/proposal/freshness"; import { getE2EBridge } from "@/lib/e2eMock"; export type DiscoveredProposal = { @@ -42,6 +46,17 @@ export function useProposalDiscovery(governorContractId?: string) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [empty, setEmpty] = useState(false); + const [freshnessMeta, setFreshnessMeta] = useState<{ + latestLedger: number | null; + lastEventLedger: number | null; + discoveredCount: number; + hadError: boolean; + }>({ + latestLedger: null, + lastEventLedger: null, + discoveredCount: 0, + hadError: false, + }); const discover = useCallback(async () => { const governor = governorContractId ?? requireContractIds().governor; @@ -57,10 +72,19 @@ export function useProposalDiscovery(governorContractId?: string) { if (mocked) { setProposals(mocked); setEmpty(mocked.length === 0); + setFreshnessMeta({ + latestLedger: null, + lastEventLedger: null, + discoveredCount: mocked.length, + hadError: false, + }); return true; } const discovered: DiscoveredProposal[] = []; let cursor: string | undefined = undefined; + let latestLedger: number | null = null; + let lastEventLedger: number | null = null; + let hadError = false; for (;;) { // Topic filters against current testnet RPC return empty for OZ @@ -89,7 +113,18 @@ export function useProposalDiscovery(governorContractId?: string) { limit: 100, }; - const response = await server.getEvents(request); + let response: Awaited>; + try { + response = await server.getEvents(request); + } catch { + hadError = true; + break; + } + + // Capture the RPC head ledger from the first page. + if (latestLedger === null && response.latestLedger) { + latestLedger = response.latestLedger; + } for (const event of response.events) { if (event.topic.length < 2) continue; @@ -109,6 +144,16 @@ export function useProposalDiscovery(governorContractId?: string) { id: Buffer.from(proposalIdBytes).toString("hex"), description: extractDescription(event), }); + + // Track the highest ledger sequence from event metadata. + const eventLedger = + typeof event.ledger === "number" ? event.ledger : null; + if (eventLedger !== null) { + lastEventLedger = + lastEventLedger === null + ? eventLedger + : Math.max(lastEventLedger, eventLedger); + } } if (!response.events.length || !response.cursor) break; @@ -118,9 +163,16 @@ export function useProposalDiscovery(governorContractId?: string) { discovered.reverse(); setProposals(discovered); setEmpty(discovered.length === 0); + setFreshnessMeta({ + latestLedger, + lastEventLedger, + discoveredCount: discovered.length, + hadError, + }); return true; } catch (err: unknown) { setError(err instanceof Error ? err.message : "Discovery failed"); + setFreshnessMeta((prev) => ({ ...prev, hadError: true })); return false; } finally { setLoading(false); @@ -137,12 +189,18 @@ export function useProposalDiscovery(governorContractId?: string) { const proposalIds = proposals.map((proposal) => proposal.id); + const freshness: FreshnessResult = useMemo( + () => evaluateDiscoveryFreshness(freshnessMeta), + [freshnessMeta], + ); + return { proposals, proposalIds, loading, error, empty, + freshness, refresh: discover, }; } diff --git a/apps/web/src/lib/proposal/freshness.test.ts b/apps/web/src/lib/proposal/freshness.test.ts new file mode 100644 index 0000000..783c6a1 --- /dev/null +++ b/apps/web/src/lib/proposal/freshness.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateDiscoveryFreshness, + CURRENT_THRESHOLD, + STALE_THRESHOLD, +} from "./freshness"; +import type { FreshnessMetadata } from "./freshness"; + +// --------------------------------------------------------------------------- +// Shared test fixtures +// --------------------------------------------------------------------------- + +function meta(overrides: Partial = {}): FreshnessMetadata { + return { + latestLedger: 1_000_100, + lastEventLedger: 1_000_095, + discoveredCount: 3, + hadError: false, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Current state +// --------------------------------------------------------------------------- + +describe("evaluateDiscoveryFreshness", () => { + describe("current", () => { + it("returns current when gap is zero", () => { + const result = evaluateDiscoveryFreshness( + meta({ latestLedger: 1_000_100, lastEventLedger: 1_000_100 }), + ); + expect(result.state).toBe("current"); + expect(result.ledgerGap).toBe(0); + }); + + it("returns current when gap is within CURRENT_THRESHOLD", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 1_000_100, + lastEventLedger: 1_000_100 - CURRENT_THRESHOLD, + }), + ); + expect(result.state).toBe("current"); + expect(result.ledgerGap).toBe(CURRENT_THRESHOLD); + }); + + it("returns current when lastEventLedger exceeds latestLedger (negative gap)", () => { + const result = evaluateDiscoveryFreshness( + meta({ latestLedger: 1_000_090, lastEventLedger: 1_000_100 }), + ); + expect(result.state).toBe("current"); + expect(result.ledgerGap).toBe(-10); + }); + }); + + // ------------------------------------------------------------------------- + // Delayed state + // ------------------------------------------------------------------------- + + describe("delayed", () => { + it("returns delayed when gap exceeds CURRENT_THRESHOLD but is within STALE_THRESHOLD", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 1_000_200, + lastEventLedger: 1_000_100, + }), + ); + expect(result.state).toBe("delayed"); + expect(result.ledgerGap).toBe(100); + }); + + it("returns delayed when gap equals STALE_THRESHOLD", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 1_000_100 + STALE_THRESHOLD, + lastEventLedger: 1_000_100, + }), + ); + expect(result.state).toBe("delayed"); + expect(result.ledgerGap).toBe(STALE_THRESHOLD); + }); + + it("returns delayed when latestLedger is null (unknown head)", () => { + const result = evaluateDiscoveryFreshness( + meta({ latestLedger: null }), + ); + expect(result.state).toBe("delayed"); + expect(result.ledgerGap).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // Stale state + // ------------------------------------------------------------------------- + + describe("stale", () => { + it("returns stale when gap exceeds STALE_THRESHOLD", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 1_000_100 + STALE_THRESHOLD + 1, + lastEventLedger: 1_000_100, + }), + ); + expect(result.state).toBe("stale"); + expect(result.ledgerGap).toBe(STALE_THRESHOLD + 1); + }); + + it("returns stale when hadError is true and data exists", () => { + const result = evaluateDiscoveryFreshness( + meta({ hadError: true, discoveredCount: 2 }), + ); + expect(result.state).toBe("stale"); + }); + + it("stale result includes explanatory message about network errors", () => { + const result = evaluateDiscoveryFreshness( + meta({ hadError: true, discoveredCount: 5 }), + ); + expect(result.explanation).toContain("network errors"); + }); + }); + + // ------------------------------------------------------------------------- + // Unavailable state + // ------------------------------------------------------------------------- + + describe("unavailable", () => { + it("returns unavailable when no events and RPC failed", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: null, + lastEventLedger: null, + discoveredCount: 0, + hadError: true, + }), + ); + expect(result.state).toBe("unavailable"); + expect(result.ledgerGap).toBeNull(); + }); + + it("returns unavailable when no events and no RPC head", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: null, + lastEventLedger: null, + discoveredCount: 0, + hadError: false, + }), + ); + expect(result.state).toBe("unavailable"); + }); + + it("returns unavailable when no events found in scanned range", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 1_000_100, + lastEventLedger: null, + discoveredCount: 0, + hadError: false, + }), + ); + expect(result.state).toBe("unavailable"); + }); + }); + + // ------------------------------------------------------------------------- + // Malformed / edge-case RPC metadata + // ------------------------------------------------------------------------- + + describe("malformed RPC metadata", () => { + it("handles zero latestLedger gracefully", () => { + const result = evaluateDiscoveryFreshness( + meta({ latestLedger: 0, lastEventLedger: 0, discoveredCount: 1 }), + ); + expect(result.state).toBe("current"); + expect(result.ledgerGap).toBe(0); + }); + + it("handles lastEventLedger greater than latestLedger", () => { + const result = evaluateDiscoveryFreshness( + meta({ latestLedger: 100, lastEventLedger: 200, discoveredCount: 1 }), + ); + expect(result.state).toBe("current"); + expect(result.ledgerGap).toBe(-100); + }); + + it("handles both ledger fields as null with no data", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: null, + lastEventLedger: null, + discoveredCount: 0, + hadError: false, + }), + ); + expect(result.state).toBe("unavailable"); + }); + + it("handles both ledger fields as null but data exists", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: null, + lastEventLedger: null, + discoveredCount: 2, + hadError: false, + }), + ); + // Has data but no way to compute gap → delayed (unknown head) + expect(result.state).toBe("delayed"); + }); + + it("handles negative latestLedger from malformed RPC", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: -1, + lastEventLedger: 100, + discoveredCount: 1, + }), + ); + // Negative head means gap = -1 - 100 = -101 → current (negative gap) + expect(result.state).toBe("current"); + }); + + it("handles discoveredCount as zero with lastEventLedger present (defensive)", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 1_000_100, + lastEventLedger: 1_000_095, + discoveredCount: 0, + }), + ); + // Edge case: lastEventLedger set but no proposals counted. + // This shouldn't happen in practice but the function handles it. + expect(result.state).toBe("current"); + }); + + it("handles very large gap (RPC retention overflow)", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 10_000_000, + lastEventLedger: 1_000_000, + discoveredCount: 5, + }), + ); + expect(result.state).toBe("stale"); + expect(result.ledgerGap).toBe(9_000_000); + }); + }); + + // ------------------------------------------------------------------------- + // Explanation strings + // ------------------------------------------------------------------------- + + describe("explanations", () => { + it("current explanation is actionable", () => { + const result = evaluateDiscoveryFreshness(meta()); + expect(result.explanation).toBeTruthy(); + expect(typeof result.explanation).toBe("string"); + }); + + it("delayed explanation mentions proposals may not appear", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 1_000_200, + lastEventLedger: 1_000_100, + }), + ); + expect(result.explanation).toContain("behind"); + }); + + it("stale explanation mentions incompleteness", () => { + const result = evaluateDiscoveryFreshness( + meta({ + latestLedger: 1_000_300, + lastEventLedger: 1_000_100, + }), + ); + expect(result.explanation).toContain("incomplete"); + }); + + it("unavailable explanation is non-empty", () => { + const result = evaluateDiscoveryFreshness( + meta({ discoveredCount: 0, lastEventLedger: null, hadError: true }), + ); + expect(result.explanation.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/apps/web/src/lib/proposal/freshness.ts b/apps/web/src/lib/proposal/freshness.ts new file mode 100644 index 0000000..b3f9e29 --- /dev/null +++ b/apps/web/src/lib/proposal/freshness.ts @@ -0,0 +1,156 @@ +/** + * Proposal discovery freshness evaluation. + * + * Derives a user-facing freshness label from the metadata returned by + * paginated RPC event scans — never from wall-clock guesses alone. + * + * Thresholds follow PRD §5.1 as adapted for direct browser-to-RPC + * discovery (no backend indexer): + * + * Current – scan completed, last event ledger within {@link CURRENT_THRESHOLD} + * of the RPC's latest ledger, no errors. + * Delayed – scan completed but the gap between last event and latest + * ledger exceeds {@link CURRENT_THRESHOLD} and is within + * {@link STALE_THRESHOLD}. + * Stale – gap exceeds {@link STALE_THRESHOLD}, or the RPC reported + * errors during pagination even though some data was returned. + * Unavailable – no data was returned (empty range or complete RPC failure). + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type FreshnessState = "current" | "delayed" | "stale" | "unavailable"; + +/** + * Metadata extracted from paginated RPC event responses. + * All fields are nullable because the RPC may not return them or the + * discovery scan may not have reached a page that provides them. + */ +export interface FreshnessMetadata { + /** Latest ledger known to the RPC at the time of the first page. */ + latestLedger: number | null; + /** Highest ledger sequence observed across all returned events. */ + lastEventLedger: number | null; + /** Number of proposals successfully discovered. */ + discoveredCount: number; + /** Whether any paginated page returned an error. */ + hadError: boolean; +} + +/** + * Full result produced by {@link evaluateDiscoveryFreshness}. + * Includes the state plus diagnostic fields for the UI banner. + */ +export interface FreshnessResult { + state: FreshnessState; + /** Human-readable explanation for the current state. */ + explanation: string; + /** Gap between latest ledger and last event ledger, or null if unknown. */ + ledgerGap: number | null; +} + +// --------------------------------------------------------------------------- +// Thresholds (ledgers) +// --------------------------------------------------------------------------- + +/** + * Maximum gap between `lastEventLedger` and `latestLedger` for the data + * to be considered Current. Five ledgers ≈ a few seconds of network time. + */ +export const CURRENT_THRESHOLD = 5; + +/** + * Gap beyond which data is considered Stale rather than merely Delayed. + * One hundred ledgers ≈ ~50 seconds on Stellar mainnet. + */ +export const STALE_THRESHOLD = 100; + +// --------------------------------------------------------------------------- +// Evaluation +// --------------------------------------------------------------------------- + +/** + * Evaluate the freshness of a proposal discovery result from RPC metadata. + * + * The function is pure — no side-effects, no React dependency, no + * wall-clock access. It can run in Node, a browser, or a test. + */ +export function evaluateDiscoveryFreshness( + meta: FreshnessMetadata, +): FreshnessResult { + const { latestLedger, lastEventLedger, discoveredCount, hadError } = meta; + + // --- Unavailable: no data at all --- + if (discoveredCount === 0 && lastEventLedger === null) { + // If the RPC itself failed, it's unavailable regardless. + if (hadError || latestLedger === null) { + return { + state: "unavailable", + explanation: "Proposal history could not be loaded.", + ledgerGap: null, + }; + } + + // RPC responded but no events found — distinguish "no proposals exist" + // from "scan range is too narrow". Without a backend indexer we cannot + // know, so report unavailable with an actionable hint. + return { + state: "unavailable", + explanation: "No proposal events were found in the scanned ledger range.", + ledgerGap: null, + }; + } + + // --- Compute the ledger gap --- + const gap = + latestLedger !== null && lastEventLedger !== null + ? latestLedger - lastEventLedger + : null; + + // --- Errors during pagination → Stale (data exists but is unreliable) --- + if (hadError && discoveredCount > 0) { + return { + state: "stale", + explanation: + "Some proposal history could not be loaded due to network errors.", + ledgerGap: gap, + }; + } + + // --- No RPC head available (shouldn't happen with a live RPC) --- + if (latestLedger === null) { + return { + state: "delayed", + explanation: "Proposal history is loading but the network head is unknown.", + ledgerGap: null, + }; + } + + // --- Gap-based classification --- + if (gap !== null && gap <= CURRENT_THRESHOLD) { + return { + state: "current", + explanation: "Proposal history is up to date.", + ledgerGap: gap, + }; + } + + if (gap !== null && gap <= STALE_THRESHOLD) { + return { + state: "delayed", + explanation: + "Proposal history is slightly behind the network. New proposals may not appear yet.", + ledgerGap: gap, + }; + } + + // Gap > STALE_THRESHOLD + return { + state: "stale", + explanation: + "Proposal history is significantly behind the network. Results may be incomplete.", + ledgerGap: gap, + }; +} diff --git a/apps/web/src/lib/proposal/index.ts b/apps/web/src/lib/proposal/index.ts index 92bc9bd..3dc4212 100644 --- a/apps/web/src/lib/proposal/index.ts +++ b/apps/web/src/lib/proposal/index.ts @@ -12,3 +12,10 @@ export { stableEventIdentity, } from "./dedupe"; export type { ProposalDiscoveryIdentityFields } from "./dedupe"; + +export { + evaluateDiscoveryFreshness, + CURRENT_THRESHOLD, + STALE_THRESHOLD, +} from "./freshness"; +export type { FreshnessState, FreshnessMetadata, FreshnessResult } from "./freshness"; diff --git a/apps/web/src/test/proposal-discovery.test.tsx b/apps/web/src/test/proposal-discovery.test.tsx index 86d118b..4078780 100644 --- a/apps/web/src/test/proposal-discovery.test.tsx +++ b/apps/web/src/test/proposal-discovery.test.tsx @@ -9,6 +9,11 @@ vi.mock("@/hooks/useProposalDiscovery", () => ({ loading: false, error: null, empty: false, + freshness: { + state: "current", + explanation: "Proposal history is up to date.", + ledgerGap: 0, + }, refresh: vi.fn(), })), }));