diff --git a/components/events/__tests__/events-section.refresh-state.test.tsx b/components/events/__tests__/events-section.refresh-state.test.tsx new file mode 100644 index 00000000..97060247 --- /dev/null +++ b/components/events/__tests__/events-section.refresh-state.test.tsx @@ -0,0 +1,74 @@ +import { fireEvent, render, screen } from "@testing-library/react" + +import { EventsSection } from "../events-section" + +const mockLoadEvents = jest.fn() +const mockRetryLoadEvents = jest.fn() +let mockEventsState: Record + +jest.mock("@/lib/events-store", () => ({ + useEventsStore: () => mockEventsState, + getEventCounts: () => ({ ongoing: 0, upcoming: 0, past: 0 }), +})) + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})) + +jest.mock("../events-toolbar", () => ({ EventsToolbar: () => null })) +jest.mock("../events-table", () => ({ EventsTable: () =>
events table
})) +jest.mock("../events-grid", () => ({ EventsGrid: () =>
events grid
})) +jest.mock("../pagination", () => ({ EventsPagination: () => null })) +jest.mock("@/app/components/CompareMarketsModal", () => ({ CompareMarketsModal: () => null })) +jest.mock("@/components/market/CompareSelectionChip", () => ({ CompareSelectionChip: () => null })) + +const baseState = { + events: [], + filteredEvents: [{ id: "market-1" }], + filters: { status: "ongoing" }, + setStatus: jest.fn(), + loadEvents: mockLoadEvents, + retryLoadEvents: mockRetryLoadEvents, +} + +beforeEach(() => { + jest.clearAllMocks() + mockEventsState = { + ...baseState, + error: "Could not refresh markets. Showing the last available data.", + canRetry: true, + } +}) + +describe("EventsSection refresh state", () => { + it("keeps stale markets visible and offers a retry for transient failures", () => { + render() + + expect(screen.getByRole("alert")).toHaveTextContent( + "Could not refresh markets. Showing the last available data.", + ) + expect(screen.getByRole("alert")).toHaveTextContent("Your current page has been kept in place.") + expect(screen.getByText("events table")).toBeInTheDocument() + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(mockRetryLoadEvents).toHaveBeenCalledTimes(1) + }) + + it("does not offer a retry for permission failures", () => { + mockEventsState = { + ...baseState, + error: "You do not have permission to refresh these markets.", + canRetry: false, + } + + render() + + expect(screen.getByRole("alert")).toHaveTextContent( + "You do not have permission to refresh these markets.", + ) + expect(screen.queryByRole("button", { name: "Try again" })).not.toBeInTheDocument() + }) +}) diff --git a/components/events/events-section.tsx b/components/events/events-section.tsx index 484bb7a7..09b9de3b 100644 --- a/components/events/events-section.tsx +++ b/components/events/events-section.tsx @@ -4,7 +4,7 @@ import * as React from "react" /* NEW: Added Link for navigation to create event page */ import Link from "next/link" /* NEW: Added Plus icon for create event button */ -import { Plus, LayoutGrid, Table2 } from "lucide-react" +import { AlertTriangle, Plus, LayoutGrid, Table2 } from "lucide-react" import { cn } from "@/lib/utils" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" @@ -25,7 +25,16 @@ interface EventsSectionProps { } export function EventsSection({ className }: EventsSectionProps) { - const { events, filters, setStatus, loadEvents } = useEventsStore() + const { + events, + filteredEvents, + filters, + setStatus, + loadEvents, + retryLoadEvents, + error, + canRetry, + } = useEventsStore() const [viewMode, setViewMode] = React.useState<"table" | "grid">("table") // Get event counts for each tab @@ -130,6 +139,36 @@ export function EventsSection({ className }: EventsSectionProps) { + {error && ( +
+
+
+ {canRetry && ( + + )} +
+ )} + {/* Tab Content */} @@ -152,4 +191,4 @@ export function EventsSection({ className }: EventsSectionProps) { ) -} \ No newline at end of file +} diff --git a/components/events/events-table.tsx b/components/events/events-table.tsx index 63afb762..47e75838 100644 --- a/components/events/events-table.tsx +++ b/components/events/events-table.tsx @@ -292,7 +292,16 @@ function EventRow({ export function EventsTable({ className }: EventsTableProps) { /* MODIFIED: Added deleteEvent from store */ - const { filteredEvents, loading, pagination, deleteEvent, filters, setFilters, setSearch } = useEventsStore() + const { + filteredEvents, + loading, + lastFetchTime, + pagination, + deleteEvent, + filters, + setFilters, + setSearch, + } = useEventsStore() /* Compare store */ const { selectedIds, toggle } = useCompareStore() @@ -313,7 +322,9 @@ export function EventsTable({ className }: EventsTableProps) { const endIndex = startIndex + pagination.pageSize const paginatedEvents = filteredEvents.slice(startIndex, endIndex) - if (loading) { + // During a retry, preserve the last good page instead of replacing it with a + // skeleton. This avoids losing the user's position while live data is stale. + if (loading && (filteredEvents.length === 0 || lastFetchTime === null)) { return } @@ -452,4 +463,4 @@ export function EventsTable({ className }: EventsTableProps) { ) -} \ No newline at end of file +} diff --git a/lib/__tests__/events-store.test.ts b/lib/__tests__/events-store.test.ts index 94a07dfc..aede13b5 100644 --- a/lib/__tests__/events-store.test.ts +++ b/lib/__tests__/events-store.test.ts @@ -1,8 +1,23 @@ import { useEventsStore } from "../events-store" +import type { Event } from "@/types/events" const initialState = useEventsStore.getState() +let warnSpy: jest.SpyInstance + +const event = (id: string, title = `Market ${id}`): Event => ({ + id, + title, + txHash: `tx-${id}`, + category: "Crypto", + odds: 2, + startDate: "2026-08-01T00:00:00.000Z", + endDate: "2026-09-01T00:00:00.000Z", + status: "ongoing", + participants: 1, +}) beforeEach(() => { + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined) useEventsStore.setState({ filters: { search: "", @@ -23,12 +38,22 @@ beforeEach(() => { hasNextPage: true, isFetchingNextPage: false, nextPageRequestId: 0, + loadRequestId: 0, + loading: false, error: null, + loadErrorKind: null, + canRetry: false, + events: initialState.events, filteredEvents: initialState.events, }) useEventsStore.getState().applyFilters() }) +afterEach(() => { + jest.useRealTimers() + warnSpy.mockRestore() +}) + describe("events filter and cursor synchronization", () => { it("resets page and cursor whenever filters change", () => { useEventsStore.setState({ @@ -65,6 +90,147 @@ describe("events filter and cursor synchronization", () => { useEventsStore.getState().setFilters({ category: ["Crypto", "Crypto"] }) expect(useEventsStore.getState().filters.category).toEqual(["Crypto", "Crypto"]) - expect(useEventsStore.getState().filteredEvents).toHaveLength(1) + expect(useEventsStore.getState().filteredEvents).toHaveLength( + initialState.events.filter((item) => item.status === "ongoing" && item.category === "Crypto").length, + ) + }) +}) + +describe("stable pagination under live updates", () => { + it("keeps the visible anchor in view when markets are inserted ahead of it", () => { + const original = [event("b", "B"), event("c", "C"), event("d", "D"), event("e", "E")] + useEventsStore.setState({ + events: original, + pagination: { page: 2, pageSize: 2, total: original.length, cursor: null, filterVersion: 0 }, + }) + useEventsStore.getState().applyFilters() + const anchorId = useEventsStore.getState().filteredEvents[2].id + + const accepted = useEventsStore + .getState() + .applyLiveEvents([event("0", "0"), event("a", "A"), ...original]) + + const state = useEventsStore.getState() + const start = (state.pagination.page - 1) * state.pagination.pageSize + const visibleIds = state.filteredEvents.slice(start, start + state.pagination.pageSize).map(({ id }) => id) + expect(accepted).toBe(true) + expect(state.pagination.page).toBe(3) + expect(visibleIds).toContain(anchorId) + expect(new Set(state.filteredEvents.map(({ id }) => id)).size).toBe(state.filteredEvents.length) + }) + + it("uses the market id as a deterministic tie-breaker", () => { + const forward = [event("c", "Same"), event("a", "Same"), event("b", "Same")] + useEventsStore.getState().applyLiveEvents(forward) + const firstOrder = useEventsStore.getState().filteredEvents.map(({ id }) => id) + + useEventsStore.getState().applyLiveEvents([...forward].reverse()) + + expect(firstOrder).toEqual(["a", "b", "c"]) + expect(useEventsStore.getState().filteredEvents.map(({ id }) => id)).toEqual(firstOrder) + }) + + it("rejects duplicate and malformed snapshots atomically", () => { + const original = [event("a"), event("b")] + useEventsStore.getState().applyLiveEvents(original) + + expect(useEventsStore.getState().applyLiveEvents([event("a"), event("a")])).toBe(false) + expect(useEventsStore.getState().events).toEqual(original) + + expect(useEventsStore.getState().applyLiveEvents([{ id: "bad" }] as Event[])).toBe(false) + expect(useEventsStore.getState().events).toEqual(original) + expect(useEventsStore.getState().loadErrorKind).toBe("invalid") + expect(warnSpy).toHaveBeenCalledWith("[events-store] Rejected invalid market snapshot") + }) + + it("clamps the final page after a live deletion and resets an empty list to page one", () => { + const original = [event("a"), event("b"), event("c")] + useEventsStore.setState({ + events: original, + pagination: { page: 2, pageSize: 2, total: original.length, cursor: null, filterVersion: 0 }, + }) + useEventsStore.getState().applyFilters() + + useEventsStore.getState().applyLiveEvents([event("a"), event("b")]) + expect(useEventsStore.getState().pagination.page).toBe(1) + + useEventsStore.getState().applyLiveEvents([]) + expect(useEventsStore.getState().pagination.page).toBe(1) + expect(useEventsStore.getState().pagination.total).toBe(0) + }) + + it("normalizes invalid page and page-size inputs", () => { + useEventsStore.getState().setPagination({ page: Number.NaN, pageSize: 0 }) + expect(useEventsStore.getState().pagination.page).toBe(1) + expect(useEventsStore.getState().pagination.pageSize).toBe(5) + + useEventsStore.getState().setPagination({ page: 999 }) + const state = useEventsStore.getState() + expect(state.pagination.page).toBe(Math.ceil(state.filteredEvents.length / state.pagination.pageSize)) + }) +}) + +describe("market refresh failure and concurrency", () => { + it("allows only the newest concurrent refresh to commit", async () => { + let resolveOld: (events: Event[]) => void = () => undefined + let resolveNew: (events: Event[]) => void = () => undefined + const oldRequest = useEventsStore + .getState() + .loadEvents(() => new Promise((resolve) => { resolveOld = resolve })) + const newRequest = useEventsStore + .getState() + .loadEvents(() => new Promise((resolve) => { resolveNew = resolve })) + + resolveNew([event("new")]) + await newRequest + resolveOld([event("old")]) + await oldRequest + + expect(useEventsStore.getState().events.map(({ id }) => id)).toEqual(["new"]) + expect(useEventsStore.getState().loading).toBe(false) + }) + + it("does not let an older refresh overwrite a newer live snapshot", async () => { + let resolveRefresh: (events: Event[]) => void = () => undefined + const refresh = useEventsStore + .getState() + .loadEvents(() => new Promise((resolve) => { resolveRefresh = resolve })) + + useEventsStore.getState().applyLiveEvents([event("live")]) + resolveRefresh([event("stale")]) + await refresh + + expect(useEventsStore.getState().events.map(({ id }) => id)).toEqual(["live"]) + expect(useEventsStore.getState().loading).toBe(false) + }) + + it("retains stale data after a retryable failure and recovers on retry", async () => { + const original = [event("cached")] + useEventsStore.getState().applyLiveEvents(original) + const fetcher = jest + .fn, []>() + .mockRejectedValueOnce(new Error("private network details")) + .mockResolvedValueOnce([event("fresh")]) + + await useEventsStore.getState().loadEvents(fetcher) + expect(useEventsStore.getState().events).toEqual(original) + expect(useEventsStore.getState().error).not.toContain("private network details") + expect(useEventsStore.getState().canRetry).toBe(true) + + await useEventsStore.getState().retryLoadEvents() + expect(useEventsStore.getState().events.map(({ id }) => id)).toEqual(["fresh"]) + expect(useEventsStore.getState().error).toBeNull() + }) + + it("surfaces permission failures without offering an unsafe retry", async () => { + await useEventsStore.getState().loadEvents(async () => { + throw { status: 403, detail: "sensitive upstream response" } + }) + + const state = useEventsStore.getState() + expect(state.loadErrorKind).toBe("permission") + expect(state.canRetry).toBe(false) + expect(state.error).toBe("You do not have permission to refresh these markets.") + expect(state.error).not.toContain("sensitive") }) }) diff --git a/lib/events-store.ts b/lib/events-store.ts index dfb6bd80..0cf88ff1 100644 --- a/lib/events-store.ts +++ b/lib/events-store.ts @@ -214,12 +214,17 @@ const mockEvents: Event[] = [ }, ] +export type EventsFetcher = () => Promise +export type EventsLoadErrorKind = "invalid" | "network" | "permission" + interface EventsStore { // Data events: Event[] filteredEvents: Event[] loading: boolean error: string | null + loadErrorKind: EventsLoadErrorKind | null + canRetry: boolean // Filters and sorting filters: EventFilters @@ -237,6 +242,7 @@ interface EventsStore { isFetchingNextPage: boolean lastFetchTime: number | null nextPageRequestId: number + loadRequestId: number // Actions setFilters: (filters: Partial) => void @@ -245,8 +251,11 @@ interface EventsStore { setSearch: (search: string) => void setDateRange: (from: Date | null, to: Date | null) => void setStatus: (status: "ongoing" | "upcoming" | "past") => void - applyFilters: () => void - loadEvents: () => Promise + applyFilters: (anchorId?: string | null) => void + loadEvents: (fetcher?: EventsFetcher) => Promise + retryLoadEvents: () => Promise + /** Atomically reconcile a live snapshot while keeping the current page anchored. */ + applyLiveEvents: (events: Event[]) => boolean /** NEW: Delete an event by its id */ deleteEvent: (id: string) => void /** NEW: Load next page for infinite scroll */ @@ -260,12 +269,79 @@ interface EventsStore { // Stale time threshold: 60 seconds const STALE_TIME_MS = 60 * 1000 +let lastEventsFetcher: EventsFetcher | null = null + +const getPageAnchor = (events: Event[], page: number, pageSize: number) => + events[(page - 1) * pageSize]?.id ?? null + +const isValidEvent = (event: unknown): event is Event => { + if (!event || typeof event !== "object") return false + + const candidate = event as Partial + return ( + typeof candidate.id === "string" && + candidate.id.trim().length > 0 && + candidate.id === candidate.id.trim() && + typeof candidate.title === "string" && + candidate.title.trim().length > 0 && + typeof candidate.txHash === "string" && + ["Football", "Politics", "Crypto", "Stocks"].includes(candidate.category ?? "") && + ["ongoing", "upcoming", "past"].includes(candidate.status ?? "") && + typeof candidate.odds === "number" && + Number.isFinite(candidate.odds) && + typeof candidate.participants === "number" && + Number.isInteger(candidate.participants) && + candidate.participants >= 0 && + typeof candidate.startDate === "string" && + Number.isFinite(Date.parse(candidate.startDate)) && + typeof candidate.endDate === "string" && + Number.isFinite(Date.parse(candidate.endDate)) + ) +} + +const validateSnapshot = (events: unknown): Event[] | null => { + if (!Array.isArray(events)) return null + + const ids = new Set() + for (const event of events) { + if (!isValidEvent(event) || ids.has(event.id)) return null + ids.add(event.id) + } + + // Detach the store from caller-owned objects so later mutation cannot alter a + // committed snapshot without going through validation and reconciliation. + return events.map((event) => ({ ...event })) +} + +const classifyLoadError = (error: unknown) => { + const status = + typeof error === "object" && error !== null && "status" in error + ? Number((error as { status?: unknown }).status) + : null + + if (status === 401 || status === 403) { + return { + kind: "permission" as const, + message: "You do not have permission to refresh these markets.", + retryable: false, + } + } + + return { + kind: "network" as const, + message: "Could not refresh markets. Showing the last available data.", + retryable: true, + } +} + export const useEventsStore = create((set, get) => ({ // Initial state events: mockEvents, filteredEvents: mockEvents.filter((e) => e.status === "ongoing"), loading: false, error: null, + loadErrorKind: null, + canRetry: false, filters: { search: "", @@ -300,6 +376,7 @@ export const useEventsStore = create((set, get) => ({ isFetchingNextPage: false, lastFetchTime: null, nextPageRequestId: 0, + loadRequestId: 0, // Actions setFilters: (newFilters) => { @@ -316,9 +393,29 @@ export const useEventsStore = create((set, get) => ({ }, setPagination: (newPagination) => { - set((state) => ({ - pagination: { ...state.pagination, ...newPagination }, - })) + set((state) => { + const requestedPageSize = newPagination.pageSize ?? state.pagination.pageSize + const pageSize = + Number.isInteger(requestedPageSize) && requestedPageSize > 0 + ? requestedPageSize + : state.pagination.pageSize + const totalPages = Math.max(1, Math.ceil(state.filteredEvents.length / pageSize)) + const requestedPage = newPagination.page ?? state.pagination.page + const page = Math.min( + totalPages, + Math.max(1, Number.isInteger(requestedPage) ? requestedPage : state.pagination.page), + ) + + return { + pagination: { + ...state.pagination, + ...newPagination, + page, + pageSize, + total: state.filteredEvents.length, + }, + } + }) }, setSearch: (search) => { @@ -365,7 +462,7 @@ export const useEventsStore = create((set, get) => ({ get().applyFilters() }, - applyFilters: () => { + applyFilters: (anchorId = null) => { const { events, filters, sort } = get() // Filter events @@ -415,42 +512,123 @@ export const useEventsStore = create((set, get) => ({ if (aValue < bValue) return sort.direction === "asc" ? -1 : 1 if (aValue > bValue) return sort.direction === "asc" ? 1 : -1 - return 0 + + // A unique tie-breaker is required: live snapshots may arrive in any + // order, but equal sort values must never make rows jump between pages. + return a.id.localeCompare(b.id) }) - set((state) => ({ - filteredEvents: filtered, - pagination: { - ...state.pagination, - total: filtered.length, - filterVersion: state.filterVersion, - }, - appliedFilterVersion: state.filterVersion, - hasNextPage: state.pagination.page * state.pagination.pageSize < filtered.length, - })) + set((state) => { + const totalPages = Math.max(1, Math.ceil(filtered.length / state.pagination.pageSize)) + const anchorIndex = anchorId ? filtered.findIndex((event) => event.id === anchorId) : -1 + const page = + anchorIndex >= 0 + ? Math.floor(anchorIndex / state.pagination.pageSize) + 1 + : Math.min(Math.max(state.pagination.page, 1), totalPages) + + return { + filteredEvents: filtered, + pagination: { + ...state.pagination, + page, + total: filtered.length, + filterVersion: state.filterVersion, + }, + appliedFilterVersion: state.filterVersion, + hasNextPage: page * state.pagination.pageSize < filtered.length, + } + }) }, - loadEvents: async () => { - set({ loading: true, error: null }) + applyLiveEvents: (events) => { + const snapshot = validateSnapshot(events) + if (!snapshot) { + console.warn("[events-store] Rejected invalid market snapshot") + set({ + error: "Market refresh returned invalid data. Showing the last available data.", + loadErrorKind: "invalid", + canRetry: true, + }) + return false + } + + const state = get() + const anchorId = getPageAnchor( + state.filteredEvents, + state.pagination.page, + state.pagination.pageSize, + ) + + // Validation happens before this single commit, so a malformed or duplicate + // snapshot can never partially replace the last known-good market list. + set({ + events: snapshot, + error: null, + loadErrorKind: null, + canRetry: false, + loading: false, + lastFetchTime: Date.now(), + loadRequestId: state.loadRequestId + 1, + }) + get().applyFilters(anchorId) + return true + }, + + loadEvents: async (fetcher) => { + if (fetcher) lastEventsFetcher = fetcher + + const requestId = get().loadRequestId + 1 + set({ + loading: true, + error: null, + loadErrorKind: null, + canRetry: false, + loadRequestId: requestId, + }) + try { - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 1000)) - set({ + const snapshot = fetcher + ? await fetcher() + : await new Promise((resolve) => + setTimeout(() => resolve(get().events), 1000), + ) + + // Only the newest refresh may commit. This prevents a slow retry from + // overwriting a newer live snapshot or clearing its error state. + if (get().loadRequestId !== requestId) return + + const accepted = get().applyLiveEvents(snapshot) + if (!accepted && get().loadRequestId === requestId) set({ loading: false }) + } catch (error) { + if (get().loadRequestId !== requestId) return + + const failure = classifyLoadError(error) + console.warn("[events-store] Market refresh failed", { kind: failure.kind }) + set({ loading: false, - lastFetchTime: Date.now(), + error: failure.message, + loadErrorKind: failure.kind, + canRetry: failure.retryable, }) - get().applyFilters() - } catch (error) { - set({ loading: false, error: "Failed to load events" }) } }, + retryLoadEvents: async () => { + await get().loadEvents(lastEventsFetcher ?? undefined) + }, + /** NEW: Delete an event by id and re-apply filters */ deleteEvent: (id: string) => { + const state = get() + const anchorId = getPageAnchor( + state.filteredEvents, + state.pagination.page, + state.pagination.pageSize, + ) set((state) => ({ events: state.events.filter((event) => event.id !== id), })) - get().applyFilters() + get().applyFilters(anchorId === id ? null : anchorId) }, /** NEW: Load next page for infinite scroll */ @@ -542,4 +720,4 @@ export const getTimeRemainingColor = (timeRemainingMs: number) => { if (days > 30) return "green" if (days > 7) return "orange" return "red" -} \ No newline at end of file +}