From 8b553d55735b7defa2d3633ca8e10b568d2966f7 Mon Sep 17 00:00:00 2001 From: bilhokista <59991975+bilhokista@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:37:45 +0700 Subject: [PATCH 1/3] feat(my-predictions): status and title filters with a filtered summary --- frontend/src/lib/predictions.ts | 189 ++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 frontend/src/lib/predictions.ts diff --git a/frontend/src/lib/predictions.ts b/frontend/src/lib/predictions.ts new file mode 100644 index 00000000..d121d402 --- /dev/null +++ b/frontend/src/lib/predictions.ts @@ -0,0 +1,189 @@ +/** + * Filtering and summarising for the prediction history page. + * + * Kept out of the page component so the rules can be tested directly. The page + * owns rendering and URL state; everything here is pure. + */ + +export type PredictionStatus = + | "Active" + | "Won" + | "Lost" + | "Pending" + | "Refunded"; + +export type FilterTab = "All" | PredictionStatus; + +export const FILTER_TABS: readonly FilterTab[] = [ + "All", + "Active", + "Pending", + "Won", + "Lost", + "Refunded", +] as const; + +export interface FilterablePrediction { + marketTitle: string; + status: PredictionStatus; + /** Amount staked, e.g. "50 XLM". */ + stake: string; + /** Total returned on a win, e.g. "142.50 XLM". Absent unless won. */ + payout?: string; +} + +export interface PredictionFilters { + status: FilterTab; + /** Free-text match against the market title. */ + search: string; +} + +export const DEFAULT_FILTERS: PredictionFilters = { + status: "All", + search: "", +}; + +/** + * Leading number from an amount like "142.50 XLM". + * + * Returns 0 rather than NaN for anything unparseable: a malformed amount from + * the API should leave a total unchanged, not turn the whole summary into + * "NaN XLM". + */ +export function parseAmount(amount: string | undefined): number { + if (!amount) return 0; + const match = /-?\d+(\.\d+)?/.exec(amount); + if (!match) return 0; + const value = Number.parseFloat(match[0]); + return Number.isFinite(value) ? value : 0; +} + +/** + * Profit or loss for a single prediction. + * + * - Won: payout minus stake, i.e. the *net* gain, not the gross return. + * - Lost: the stake, negated. + * - Refunded: zero — the stake came back, so nothing was won or lost. + * - Active / Pending: zero, since the result is not known yet. Counting an + * open stake as a loss would make an untouched account look under water. + */ +export function computePnl(prediction: FilterablePrediction): number { + switch (prediction.status) { + case "Won": + return parseAmount(prediction.payout) - parseAmount(prediction.stake); + case "Lost": + return -parseAmount(prediction.stake); + default: + return 0; + } +} + +export function filterPredictions( + predictions: readonly T[], + filters: PredictionFilters, +): T[] { + const needle = filters.search.trim().toLowerCase(); + + return predictions.filter((prediction) => { + if (filters.status !== "All" && prediction.status !== filters.status) { + return false; + } + if (!needle) return true; + return prediction.marketTitle.toLowerCase().includes(needle); + }); +} + +export interface PredictionSummary { + total: number; + won: number; + lost: number; + pending: number; + active: number; + refunded: number; + /** Settled predictions, i.e. those that count towards the win rate. */ + decided: number; + /** + * Percentage of *decided* predictions that were won, rounded. + * + * Deliberately not `won / total`: while most predictions are still open, + * dividing by the total reports a win rate that falls as the user places + * more bets, which reads as though they are getting worse. `null` when + * nothing has settled yet, so the UI can say "—" instead of "0%". + */ + winRate: number | null; + /** Net profit or loss across the set, in the staking unit. */ + netPnl: number; +} + +export function summarisePredictions( + predictions: readonly FilterablePrediction[], +): PredictionSummary { + const count = (status: PredictionStatus) => + predictions.filter((p) => p.status === status).length; + + const won = count("Won"); + const lost = count("Lost"); + const decided = won + lost; + + return { + total: predictions.length, + won, + lost, + pending: count("Pending"), + active: count("Active"), + refunded: count("Refunded"), + decided, + winRate: decided > 0 ? Math.round((won / decided) * 100) : null, + netPnl: predictions.reduce((sum, p) => sum + computePnl(p), 0), + }; +} + +/** Signed amount for display, e.g. "+42.50" or "-30.00". */ +export function formatSignedAmount(value: number, digits = 2): string { + const rounded = value.toFixed(digits); + // `toFixed` on a tiny negative gives "-0.00", which reads as a loss. + if (Number.parseFloat(rounded) === 0) return (0).toFixed(digits); + return value > 0 ? `+${rounded}` : rounded; +} + +// ── URL persistence ──────────────────────────────────────────────────────── + +export const STATUS_PARAM = "status"; +export const SEARCH_PARAM = "q"; + +function isFilterTab(value: string): value is FilterTab { + return (FILTER_TABS as readonly string[]).includes(value); +} + +/** + * Reads filters from the query string, falling back to defaults. + * + * An unrecognised status is ignored rather than rejected: a stale or + * hand-edited link should still open the page, just unfiltered. + */ +export function readFiltersFromParams( + params: URLSearchParams, +): PredictionFilters { + const status = params.get(STATUS_PARAM) ?? ""; + const search = params.get(SEARCH_PARAM) ?? ""; + + return { + status: isFilterTab(status) ? status : DEFAULT_FILTERS.status, + search, + }; +} + +/** + * Query string for the given filters, omitting anything at its default so a + * pristine page keeps a clean URL. + */ +export function buildFilterQuery(filters: PredictionFilters): string { + const params = new URLSearchParams(); + if (filters.status !== DEFAULT_FILTERS.status) { + params.set(STATUS_PARAM, filters.status); + } + if (filters.search.trim()) { + params.set(SEARCH_PARAM, filters.search.trim()); + } + return params.toString(); +} From 2ce0db6d56705010f18426851904a82909af6d1b Mon Sep 17 00:00:00 2001 From: bilhokista <59991975+bilhokista@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:37:48 +0700 Subject: [PATCH 2/3] feat(my-predictions): status and title filters with a filtered summary --- frontend/src/lib/predictions.test.ts | 205 +++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 frontend/src/lib/predictions.test.ts diff --git a/frontend/src/lib/predictions.test.ts b/frontend/src/lib/predictions.test.ts new file mode 100644 index 00000000..001795e1 --- /dev/null +++ b/frontend/src/lib/predictions.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_FILTERS, + FilterablePrediction, + buildFilterQuery, + computePnl, + filterPredictions, + formatSignedAmount, + parseAmount, + readFiltersFromParams, + summarisePredictions, +} from "./predictions"; + +function make( + overrides: Partial & { marketTitle: string }, +): FilterablePrediction { + return { status: "Active", stake: "50 XLM", ...overrides }; +} + +/** Mirrors the shape of the page's fixture data. */ +const SAMPLE: FilterablePrediction[] = [ + make({ marketTitle: "Will XLM close above $0.20 this week?" }), + make({ marketTitle: "Bitcoin price above $100k by year end?", stake: "25 XLM" }), + make({ + marketTitle: "Ethereum ETF approval by end of month", + status: "Won", + stake: "75 XLM", + payout: "142.50 XLM", + }), + make({ + marketTitle: "Will top 10 DeFi TVL increase this week?", + status: "Lost", + stake: "30 XLM", + }), + make({ marketTitle: "NFT market cap to reach $50B?", status: "Pending", stake: "40 XLM" }), + make({ marketTitle: "Cancelled market", status: "Refunded", stake: "10 XLM" }), +]; + +describe("parseAmount", () => { + it("reads the leading number out of an amount", () => { + expect(parseAmount("142.50 XLM")).toBe(142.5); + expect(parseAmount("50 XLM")).toBe(50); + }); + + it("returns 0 rather than NaN for missing or unparseable input", () => { + // A malformed amount must leave a total unchanged, not poison it. + expect(parseAmount(undefined)).toBe(0); + expect(parseAmount("")).toBe(0); + expect(parseAmount("XLM")).toBe(0); + }); +}); + +describe("computePnl", () => { + it("counts a win as payout minus stake, not the gross payout", () => { + expect( + computePnl(make({ marketTitle: "m", status: "Won", stake: "75 XLM", payout: "142.50 XLM" })), + ).toBe(67.5); + }); + + it("counts a loss as the negated stake", () => { + expect(computePnl(make({ marketTitle: "m", status: "Lost", stake: "30 XLM" }))).toBe(-30); + }); + + it("counts a refund as neutral", () => { + expect(computePnl(make({ marketTitle: "m", status: "Refunded", stake: "10 XLM" }))).toBe(0); + }); + + it("does not treat an open stake as a loss", () => { + // Otherwise an untouched account with open bets looks under water. + expect(computePnl(make({ marketTitle: "m", status: "Active" }))).toBe(0); + expect(computePnl(make({ marketTitle: "m", status: "Pending" }))).toBe(0); + }); +}); + +describe("filterPredictions", () => { + it("returns everything with the default filters", () => { + expect(filterPredictions(SAMPLE, DEFAULT_FILTERS)).toHaveLength(SAMPLE.length); + }); + + it("narrows to a single status", () => { + const won = filterPredictions(SAMPLE, { status: "Won", search: "" }); + expect(won).toHaveLength(1); + expect(won[0].marketTitle).toContain("Ethereum ETF"); + }); + + it("includes refunded predictions under their own tab", () => { + expect(filterPredictions(SAMPLE, { status: "Refunded", search: "" })).toHaveLength(1); + }); + + it("searches the market title case-insensitively", () => { + expect(filterPredictions(SAMPLE, { status: "All", search: "BITCOIN" })).toHaveLength(1); + expect(filterPredictions(SAMPLE, { status: "All", search: "week" })).toHaveLength(2); + }); + + it("ignores surrounding whitespace in the search term", () => { + expect(filterPredictions(SAMPLE, { status: "All", search: " " })).toHaveLength( + SAMPLE.length, + ); + expect(filterPredictions(SAMPLE, { status: "All", search: " bitcoin " })).toHaveLength(1); + }); + + it("applies status and search together", () => { + expect(filterPredictions(SAMPLE, { status: "Active", search: "week" })).toHaveLength(1); + expect(filterPredictions(SAMPLE, { status: "Won", search: "bitcoin" })).toHaveLength(0); + }); + + it("does not mutate the input", () => { + const copy = [...SAMPLE]; + filterPredictions(SAMPLE, { status: "Won", search: "e" }); + expect(SAMPLE).toEqual(copy); + }); +}); + +describe("summarisePredictions", () => { + it("counts each status across the whole set", () => { + const s = summarisePredictions(SAMPLE); + expect(s).toMatchObject({ + total: 6, + won: 1, + lost: 1, + active: 2, + pending: 1, + refunded: 1, + decided: 2, + }); + }); + + it("computes the win rate over settled predictions only", () => { + // 1 won of 2 settled = 50%. Over all six it would read 17% and fall + // further with every new open bet. + expect(summarisePredictions(SAMPLE).winRate).toBe(50); + }); + + it("reports no win rate when nothing has settled", () => { + const open = SAMPLE.filter((p) => p.status === "Active"); + expect(summarisePredictions(open).winRate).toBeNull(); + }); + + it("nets wins against losses", () => { + // +67.50 from the win, -30 from the loss. + expect(summarisePredictions(SAMPLE).netPnl).toBeCloseTo(37.5); + }); + + it("summarises an empty set without dividing by zero", () => { + expect(summarisePredictions([])).toMatchObject({ + total: 0, + decided: 0, + winRate: null, + netPnl: 0, + }); + }); + + it("reflects the filtered set rather than the whole history", () => { + const lost = filterPredictions(SAMPLE, { status: "Lost", search: "" }); + const s = summarisePredictions(lost); + + expect(s.total).toBe(1); + expect(s.winRate).toBe(0); + expect(s.netPnl).toBe(-30); + }); +}); + +describe("formatSignedAmount", () => { + it("marks a gain with a plus sign", () => { + expect(formatSignedAmount(37.5)).toBe("+37.50"); + }); + + it("keeps the minus sign on a loss", () => { + expect(formatSignedAmount(-30)).toBe("-30.00"); + }); + + it("never renders a negative zero", () => { + // "-0.00" reads as a loss when the user broke even. + expect(formatSignedAmount(0)).toBe("0.00"); + expect(formatSignedAmount(-0.0001)).toBe("0.00"); + }); +}); + +describe("URL persistence", () => { + it("round-trips filters through the query string", () => { + const filters = { status: "Won" as const, search: "bitcoin" }; + const restored = readFiltersFromParams(new URLSearchParams(buildFilterQuery(filters))); + expect(restored).toEqual(filters); + }); + + it("keeps a pristine URL clean", () => { + expect(buildFilterQuery(DEFAULT_FILTERS)).toBe(""); + }); + + it("omits a search term that is only whitespace", () => { + expect(buildFilterQuery({ status: "All", search: " " })).toBe(""); + }); + + it("falls back to defaults for an unknown status instead of failing", () => { + // A stale or hand-edited link should still open the page. + expect(readFiltersFromParams(new URLSearchParams("status=Bogus"))).toEqual( + DEFAULT_FILTERS, + ); + }); + + it("reads defaults from an empty query string", () => { + expect(readFiltersFromParams(new URLSearchParams(""))).toEqual(DEFAULT_FILTERS); + }); +}); From 4c8f552caaca23608ab4aef301c12d9c7bc9ac92 Mon Sep 17 00:00:00 2001 From: bilhokista <59991975+bilhokista@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:37:51 +0700 Subject: [PATCH 3/3] feat(my-predictions): status and title filters with a filtered summary --- .../(authenticated)/my-predictions/page.tsx | 256 +++++++++++++----- 1 file changed, 188 insertions(+), 68 deletions(-) diff --git a/frontend/src/app/(authenticated)/my-predictions/page.tsx b/frontend/src/app/(authenticated)/my-predictions/page.tsx index 3354c171..050372c2 100644 --- a/frontend/src/app/(authenticated)/my-predictions/page.tsx +++ b/frontend/src/app/(authenticated)/my-predictions/page.tsx @@ -1,14 +1,24 @@ "use client"; -import { useState, useMemo } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { ChevronLeft, ChevronRight, BarChart3 } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { ChevronLeft, ChevronRight, BarChart3, Search } from "lucide-react"; import { useConfirm } from "@/hooks/useConfirm"; import { useToast } from "@/hooks/useToast"; import { EmptyState } from "@/component/ui/empty-state"; - -type PredictionStatus = "Active" | "Won" | "Lost" | "Pending"; -type FilterTab = "All" | "Active" | "Won" | "Lost" | "Pending"; +import { + DEFAULT_FILTERS, + FILTER_TABS, + type FilterTab, + type PredictionFilters, + type PredictionStatus, + buildFilterQuery, + filterPredictions, + formatSignedAmount, + readFiltersFromParams, + summarisePredictions, +} from "@/lib/predictions"; interface Prediction { id: string; @@ -127,6 +137,8 @@ function getStatusBadgeClasses(status: PredictionStatus): string { return `${baseClasses} border border-red-500/30 bg-red-500/10 text-red-200`; case "Pending": return `${baseClasses} border border-yellow-500/30 bg-yellow-500/10 text-yellow-200`; + case "Refunded": + return `${baseClasses} border border-slate-500/30 bg-slate-500/10 text-slate-200`; default: return `${baseClasses} border border-white/10 bg-white/5 text-gray-200`; } @@ -151,19 +163,42 @@ function getCategoryBadgeClasses(category: string): string { } } -export default function MyPredictionsPage() { +/** + * `useSearchParams` opts the whole subtree into client-side rendering, and + * Next refuses to build a page that calls it outside a Suspense boundary. The + * page component below supplies one; this holds the actual screen. + */ +function MyPredictionsContent() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [predictions, setPredictions] = useState(MOCK_PREDICTIONS); - const [activeFilter, setActiveFilter] = useState("All"); + const [filters, setFilters] = useState(() => + readFiltersFromParams(new URLSearchParams(searchParams.toString())), + ); const [currentPage, setCurrentPage] = useState(1); const [claimingPredictionId, setClaimingPredictionId] = useState(null); const [claimError, setClaimError] = useState(null); const confirm = useConfirm(); const toast = useToast(); - const filteredPredictions = useMemo(() => { - if (activeFilter === "All") return predictions; - return predictions.filter((pred) => pred.status === activeFilter); - }, [predictions, activeFilter]); + const filteredPredictions = useMemo( + () => filterPredictions(predictions, filters), + [predictions, filters], + ); + + // Write the filters back to the URL so the view survives a reload and can be + // shared as a link. `replace` rather than `push`: typing in the search box + // should not bury the previous page under a stack of history entries. + useEffect(() => { + const query = buildFilterQuery(filters); + const next = query ? `${pathname}?${query}` : pathname; + const current = searchParams.toString(); + if (query !== current) { + router.replace(next, { scroll: false }); + } + }, [filters, pathname, router, searchParams]); const totalPages = Math.ceil(filteredPredictions.length / ITEMS_PER_PAGE); const paginatedPredictions = useMemo(() => { @@ -171,37 +206,47 @@ export default function MyPredictionsPage() { return filteredPredictions.slice(startIndex, startIndex + ITEMS_PER_PAGE); }, [filteredPredictions, currentPage]); - const stats = useMemo(() => { - const total = predictions.length; - const won = predictions.filter((p) => p.status === "Won").length; - const lost = predictions.filter((p) => p.status === "Lost").length; - const pending = predictions.filter((p) => p.status === "Pending").length; - - return { - total, - won, - lost, - pending, - wonPercentage: total > 0 ? Math.round((won / total) * 100) : 0, - lostPercentage: total > 0 ? Math.round((lost / total) * 100) : 0, - pendingPercentage: total > 0 ? Math.round((pending / total) * 100) : 0, - }; - }, [predictions]); + // Summarises the *filtered* set, so the chips answer "how am I doing in + // what I am looking at" rather than always restating the lifetime totals. + const stats = useMemo( + () => summarisePredictions(filteredPredictions), + [filteredPredictions], + ); + // Counts on the tabs respect the search box but not the status filter — + // otherwise every tab except the active one would read zero. const filterCounts = useMemo(() => { + const searchOnly = filterPredictions(predictions, { + ...filters, + status: "All", + }); return { - All: predictions.length, - Active: predictions.filter((p) => p.status === "Active").length, - Won: predictions.filter((p) => p.status === "Won").length, - Lost: predictions.filter((p) => p.status === "Lost").length, - Pending: predictions.filter((p) => p.status === "Pending").length, - }; - }, [predictions]); - - const handleFilterChange = (filter: FilterTab) => { - setActiveFilter(filter); + All: searchOnly.length, + Active: searchOnly.filter((p) => p.status === "Active").length, + Pending: searchOnly.filter((p) => p.status === "Pending").length, + Won: searchOnly.filter((p) => p.status === "Won").length, + Lost: searchOnly.filter((p) => p.status === "Lost").length, + Refunded: searchOnly.filter((p) => p.status === "Refunded").length, + } satisfies Record; + }, [predictions, filters]); + + const handleFilterChange = useCallback((status: FilterTab) => { + setFilters((prev) => ({ ...prev, status })); setCurrentPage(1); - }; + }, []); + + const handleSearchChange = useCallback((search: string) => { + setFilters((prev) => ({ ...prev, search })); + setCurrentPage(1); + }, []); + + const handleClearFilters = useCallback(() => { + setFilters(DEFAULT_FILTERS); + setCurrentPage(1); + }, []); + + const hasActiveFilters = + filters.status !== DEFAULT_FILTERS.status || filters.search.trim() !== ""; const handleClaimPayout = async (predictionId: string) => { const targetPrediction = predictions.find((prediction) => prediction.id === predictionId); @@ -244,7 +289,13 @@ export default function MyPredictionsPage() { variant: "destructive", }); if (!confirmed) return; - setPredictions((prev) => prev.filter((p) => p.id !== prediction.id)); + // Marked rather than removed: a refund is part of the history the user + // came here to review, and deleting the row hid it entirely. + setPredictions((prev) => + prev.map((p) => + p.id === prediction.id ? { ...p, status: "Refunded" as const } : p, + ), + ); toast.success("Prediction cancelled and stake refunded"); }; @@ -258,55 +309,96 @@ export default function MyPredictionsPage() { return (
- {/* Summary Stats Row */} -
+ {/* Summary Stats Row — reflects the filtered set, not lifetime totals. */} +

- Total Predictions + {hasActiveFilters ? "Predictions Shown" : "Total Predictions"}

-

{stats.total}

+

+ {stats.total} +

-

Won

+

Win Rate

-

{stats.won}

- - ({stats.wonPercentage}%) +

+ {stats.winRate === null ? "—" : `${stats.winRate}%`} +

+ + {stats.decided === 0 + ? "nothing settled yet" + : `${stats.won} of ${stats.decided} settled`}
-

Lost

-
-

{stats.lost}

- - ({stats.lostPercentage}%) - -
+

Net P/L

+

0 + ? "text-emerald-300" + : stats.netPnl < 0 + ? "text-red-300" + : "text-white" + }`} + data-testid="summary-net-pnl" + > + {formatSignedAmount(stats.netPnl)} XLM +

-

Pending

+

Open

-

- {stats.pending} +

+ {stats.active + stats.pending}

- - ({stats.pendingPercentage}%) - + still running
{/* Filter Tabs */}
+
+
+
+ {hasActiveFilters && ( + + )} +
+
- {(["All", "Active", "Won", "Lost", "Pending"] as FilterTab[]).map( + {FILTER_TABS.map( (filter) => (
+ } + > + + + ); +}