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 a1457ee..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,14 +5,12 @@ import { Buffer } from "buffer"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ProposalSummaryCard } from "@/components/ProposalSummaryCard"; -import { AsyncState } from "@/components/ui/AsyncState"; -import { EmptyState } from "@/components/ui/EmptyState"; -import { ErrorState } from "@/components/ui/ErrorState"; -import { FreshnessNotice } from "@/components/ui/FreshnessNotice"; +import { DiscoveryFreshnessBanner } from "@/components/DiscoveryFreshnessBanner"; +import { LiveStatus } from "@/components/ui/LiveStatus"; import { Skeleton } from "@/components/ui/Skeleton"; import { useProposalDiscovery } from "@/hooks/useProposalDiscovery"; -import { useCommunityRegistry } from "@/lib/community/CommunityRegistryProvider"; -import type { Community } from "@/lib/community/types"; +import { getCommunity } from "@/lib/community/registry"; +import type { CommunityView } from "@/lib/community/types"; import { createReadOnlyGovernorClient } from "@/lib/contracts"; import { ProposalState, @@ -23,10 +21,16 @@ import { const PAGE_SIZE = 10; const ALL_STATES = "all"; -function ScopedProposalHistory({ community }: { community: Community }) { +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( @@ -48,7 +52,7 @@ function ScopedProposalHistory({ community }: { community: Community }) { try { const client = createReadOnlyGovernorClient(governorContract); const transaction = await client.proposal_state({ - proposal_id: Buffer.from(proposalId, "hex"), + proposal_id: Uint8Array.from(Buffer.from(proposalId, "hex")), }); setStates((current) => ({ ...current, @@ -153,9 +157,9 @@ function ScopedProposalHistory({ community }: { community: Community }) { {loading && proposals.length === 0 && ( <> - + Loading community proposal history… - + )} + {!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 e767bc5..4b2ef27 100644 --- a/apps/web/src/hooks/useProposalDiscovery.ts +++ b/apps/web/src/hooks/useProposalDiscovery.ts @@ -1,54 +1,65 @@ "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 { - config, - requireContractIds, - requireGovernorStartLedger, -} from "@/lib/stellar"; -import { - decodeProposalEvent, - fetchGovernorEvents, -} from "@/lib/proposal-events"; + evaluateDiscoveryFreshness, + type FreshnessResult, +} from "@/lib/proposal/freshness"; import { getE2EBridge } from "@/lib/e2eMock"; -import { parseProposalDescription, type ProposalMetadataV1 } from "@/lib/proposal-metadata"; export type DiscoveredProposal = { id: string; /** Proposal description from the created event, or null when unavailable. */ description: string | null; - /** Parsed v1 metadata, or null for legacy / unavailable descriptions. */ - metadata: ProposalMetadataV1 | null; }; -/** - * Discover proposals for a Governor via the shared event pipeline. - * - * - Omit `governorContractId` for the legacy global surface (env Governor). - * - When Community (or any caller) passes an explicit id, that contract is - * used exclusively — never silently fall back to the env global Governor. - */ +function extractDescription(event: Api.EventResponse): string | null { + const decoded = decodeProposalEvent({ + type: event.type, + contractId: event.contractId, + topic: event.topic, + value: event.value, + }); + if (decoded.ok && decoded.event.kind === "proposal_created") { + return decoded.event.description; + } + + try { + if (event.value.switch().name !== "scvVec") return null; + const fields = event.value.vec(); + const descriptionVal = fields?.[5]; + if (!descriptionVal || descriptionVal.switch().name !== "scvString") { + return null; + } + return descriptionVal.str() as string; + } catch { + return null; + } +} + export function useProposalDiscovery(governorContractId?: string) { const [proposals, setProposals] = useState([]); 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 !== undefined - ? governorContractId - : requireContractIds().governor; - - if (!governor) { - setError( - "Governor contract ID is not configured. Set NEXT_PUBLIC_GOVERNOR_CONTRACT_ID.", - ); - setLoading(false); - return false; - } - + const governor = governorContractId ?? requireContractIds().governor; const server = new RpcServer(config.rpcUrl); const startLedger = requireGovernorStartLedger(); @@ -59,54 +70,109 @@ export function useProposalDiscovery(governorContractId?: string) { try { const mocked = getE2EBridge()?.proposals?.[governor]; if (mocked) { - setProposals(mocked.map((proposal) => ({ - ...proposal, - metadata: proposal.description - ? (() => { - const parsed = parseProposalDescription(proposal.description); - return parsed.kind === "versioned" ? parsed.metadata : null; - })() - : null, - }))); + 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 await (const page of fetchGovernorEvents({ - server, - contractId: governor, - startLedger, - })) { - for (const event of page.events) { - const decoded = decodeProposalEvent( - { - type: event.type, - contractId: event.contractId, - topic: event.topic, - value: event.value, - }, - { expectedContractId: governor }, - ); - if (!decoded.ok || decoded.event.kind !== "proposal_created") { - continue; - } - const parsed = parseProposalDescription(decoded.event.description); + for (;;) { + // Topic filters against current testnet RPC return empty for OZ + // contract-event symbols; fetch by contract id and filter client-side. + const request: + | { + filters: { contractIds: string[] }[]; + startLedger: number; + limit?: number; + cursor?: never; + } + | { + filters: { contractIds: string[] }[]; + cursor: string; + startLedger?: never; + limit?: number; + } = cursor + ? { + filters: [{ contractIds: [governor] }], + cursor, + limit: 100, + } + : { + filters: [{ contractIds: [governor] }], + startLedger, + limit: 100, + }; + + 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; + const kind = event.topic[0]; + const kindName = + kind.switch().name === "scvSymbol" + ? kind.sym().toString() + : kind.switch().name === "scvString" + ? kind.str().toString() + : ""; + if (kindName !== "proposal_created") continue; + const proposalIdScVal = event.topic[1]; + if (proposalIdScVal.switch().name !== "scvBytes") continue; + const proposalIdBytes = proposalIdScVal.bytes(); + if (!proposalIdBytes) continue; discovered.push({ - id: decoded.event.proposalId, - description: decoded.event.description, - metadata: parsed.kind === "versioned" ? parsed.metadata : null, + 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; + cursor = response.cursor; } 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); @@ -123,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-events/freshness.test.ts b/apps/web/src/lib/proposal-events/freshness.test.ts new file mode 100644 index 0000000..783c6a1 --- /dev/null +++ b/apps/web/src/lib/proposal-events/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-events/freshness.ts b/apps/web/src/lib/proposal-events/freshness.ts index 129dabb..b3f9e29 100644 --- a/apps/web/src/lib/proposal-events/freshness.ts +++ b/apps/web/src/lib/proposal-events/freshness.ts @@ -1,56 +1,156 @@ /** - * Client-side Freshness State types matching - * `docs/community-proposal-indexing.md` public sync contract. + * Proposal discovery freshness evaluation. * - * Until the shared indexer lands, direct-RPC discovery can surface a - * stub sync payload so Community and global consumers share one shape. + * 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). */ -/** Indexer / discovery pipeline status for a community proposal feed. */ -export type ProposalSyncStatus = - | "ready" - | "syncing" - | "partial" - | "unavailable"; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type FreshnessState = "current" | "delayed" | "stale" | "unavailable"; /** - * Freshness label relative to observed head and finality lag. - * See community-proposal-indexing.md § Finality, freshness, and caching. + * 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 type ProposalFreshness = "fresh" | "stale" | "unknown"; - -export interface ProposalSyncState { - status: ProposalSyncStatus; - freshness: ProposalFreshness; - indexedThroughLedger: number | null; - observedHeadLedger: number | null; - lastSuccessfulSync: string | null; - warnings: string[]; +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; } -export interface ClientFreshnessStubOptions { - status?: ProposalSyncStatus; - freshness?: ProposalFreshness; - indexedThroughLedger?: number | null; - observedHeadLedger?: number | null; - lastSuccessfulSync?: string | null; - warnings?: string[]; +/** + * 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 +// --------------------------------------------------------------------------- + /** - * Build a client-side sync stub for direct-RPC discovery. - * Not a substitute for indexer freshness; call sites must not present - * this as authoritative multi-community indexing state. + * 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 createClientFreshnessStub( - options: ClientFreshnessStubOptions = {}, -): ProposalSyncState { +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 { - status: options.status ?? "ready", - freshness: options.freshness ?? "unknown", - indexedThroughLedger: options.indexedThroughLedger ?? null, - observedHeadLedger: options.observedHeadLedger ?? null, - lastSuccessfulSync: options.lastSuccessfulSync ?? null, - warnings: options.warnings ?? [], + state: "stale", + explanation: + "Proposal history is significantly behind the network. Results may be incomplete.", + ledgerGap: gap, }; } diff --git a/apps/web/src/test/proposal-discovery.test.tsx b/apps/web/src/test/proposal-discovery.test.tsx index d52f710..f682ea5 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(), })), }));