Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions components/events/__tests__/events-section.refresh-state.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>

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 }) => (
<a href={href}>{children}</a>
),
}))

jest.mock("../events-toolbar", () => ({ EventsToolbar: () => null }))
jest.mock("../events-table", () => ({ EventsTable: () => <div>events table</div> }))
jest.mock("../events-grid", () => ({ EventsGrid: () => <div>events grid</div> }))
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(<EventsSection />)

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(<EventsSection />)

expect(screen.getByRole("alert")).toHaveTextContent(
"You do not have permission to refresh these markets.",
)
expect(screen.queryByRole("button", { name: "Try again" })).not.toBeInTheDocument()
})
})
45 changes: 42 additions & 3 deletions components/events/events-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -130,6 +139,36 @@ export function EventsSection({ className }: EventsSectionProps) {
</Tabs>
</div>

{error && (
<div
role="alert"
className="flex flex-col gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" aria-hidden="true" />
<p>
{error}
{filteredEvents.length > 0 && (
<span className="ml-1 text-muted-foreground">
Your current page has been kept in place.
</span>
)}
</p>
</div>
{canRetry && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void retryLoadEvents()}
className="shrink-0"
>
Try again
</Button>
)}
</div>
)}

{/* Tab Content */}
<Tabs value={filters.status} onValueChange={handleTabChange} className="w-full">
<TabsContent value="ongoing" className="space-y-6 mt-6">
Expand All @@ -152,4 +191,4 @@ export function EventsSection({ className }: EventsSectionProps) {
</Tabs>
</div>
)
}
}
17 changes: 14 additions & 3 deletions components/events/events-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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 <EventsTableSkeleton />
}

Expand Down Expand Up @@ -452,4 +463,4 @@ export function EventsTable({ className }: EventsTableProps) {
</div>
</div>
)
}
}
168 changes: 167 additions & 1 deletion lib/__tests__/events-store.test.ts
Original file line number Diff line number Diff line change
@@ -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: "",
Expand All @@ -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({
Expand Down Expand Up @@ -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<Promise<Event[]>, []>()
.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")
})
})
Loading
Loading