Skip to content

Commit c67b5e5

Browse files
committed
feat: implement date range and outcome filters on bet history table
1 parent 7fb9e2b commit c67b5e5

3 files changed

Lines changed: 454 additions & 2 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"use client";
2+
/**
3+
* BetHistoryFilters
4+
*
5+
* Filter bar for BetHistoryTable: date range, outcome, market category.
6+
* Syncs state with URL query params. Shows active filter count badge.
7+
*/
8+
import { useCallback, useEffect, useState } from "react";
9+
import { useRouter, useSearchParams, usePathname } from "next/navigation";
10+
import { BetRow } from "./BetHistoryTable";
11+
12+
export type OutcomeFilter = "All" | "Win" | "Loss" | "Pending";
13+
14+
export interface FilterState {
15+
startDate: string;
16+
endDate: string;
17+
outcome: OutcomeFilter;
18+
category: string;
19+
}
20+
21+
export const DEFAULT_FILTERS: FilterState = {
22+
startDate: "",
23+
endDate: "",
24+
outcome: "All",
25+
category: "",
26+
};
27+
28+
// ── URL sync helpers ──────────────────────────────────────────────────────────
29+
30+
export function filtersToParams(f: FilterState): URLSearchParams {
31+
const p = new URLSearchParams();
32+
if (f.startDate) p.set("startDate", f.startDate);
33+
if (f.endDate) p.set("endDate", f.endDate);
34+
if (f.outcome !== "All") p.set("outcome", f.outcome);
35+
if (f.category) p.set("category", f.category);
36+
return p;
37+
}
38+
39+
export function paramsToFilters(params: URLSearchParams): FilterState {
40+
return {
41+
startDate: params.get("startDate") ?? "",
42+
endDate: params.get("endDate") ?? "",
43+
outcome: (params.get("outcome") as OutcomeFilter) ?? "All",
44+
category: params.get("category") ?? "",
45+
};
46+
}
47+
48+
export function countActiveFilters(f: FilterState): number {
49+
let n = 0;
50+
if (f.startDate) n++;
51+
if (f.endDate) n++;
52+
if (f.outcome !== "All") n++;
53+
if (f.category) n++;
54+
return n;
55+
}
56+
57+
// ── Client-side filter logic ──────────────────────────────────────────────────
58+
59+
export function applyFilters(rows: BetRow[], filters: FilterState): BetRow[] {
60+
return rows.filter((row) => {
61+
if (filters.startDate) {
62+
if (new Date(row.date) < new Date(filters.startDate)) return false;
63+
}
64+
if (filters.endDate) {
65+
// Include the full end day
66+
const end = new Date(filters.endDate);
67+
end.setHours(23, 59, 59, 999);
68+
if (new Date(row.date) > end) return false;
69+
}
70+
if (filters.outcome !== "All" && row.result !== filters.outcome) return false;
71+
if (filters.category && !row.marketTitle.toLowerCase().includes(filters.category.toLowerCase()))
72+
return false;
73+
return true;
74+
});
75+
}
76+
77+
// ── Component ─────────────────────────────────────────────────────────────────
78+
79+
interface Props {
80+
filters: FilterState;
81+
onChange: (f: FilterState) => void;
82+
onClear: () => void;
83+
}
84+
85+
const OUTCOMES: OutcomeFilter[] = ["All", "Win", "Loss", "Pending"];
86+
87+
export default function BetHistoryFilters({ filters, onChange, onClear }: Props) {
88+
const [open, setOpen] = useState(false);
89+
const activeCount = countActiveFilters(filters);
90+
91+
const set = useCallback(
92+
(key: keyof FilterState, value: string) => onChange({ ...filters, [key]: value }),
93+
[filters, onChange]
94+
);
95+
96+
return (
97+
<div className="flex flex-col gap-2">
98+
{/* Toggle button */}
99+
<div className="flex items-center gap-2">
100+
<button
101+
onClick={() => setOpen((o) => !o)}
102+
className="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 text-white text-sm font-medium rounded-lg transition-colors"
103+
aria-expanded={open}
104+
aria-label="Toggle filters"
105+
>
106+
Filters
107+
{activeCount > 0 && (
108+
<span
109+
className="flex items-center justify-center w-5 h-5 bg-indigo-600 text-white text-xs font-bold rounded-full"
110+
data-testid="filter-badge"
111+
>
112+
{activeCount}
113+
</span>
114+
)}
115+
</button>
116+
{activeCount > 0 && (
117+
<button
118+
onClick={onClear}
119+
className="px-3 py-2 text-sm text-gray-400 hover:text-white transition-colors"
120+
aria-label="Clear filters"
121+
>
122+
Clear Filters
123+
</button>
124+
)}
125+
</div>
126+
127+
{/* Filter bar */}
128+
{open && (
129+
<div className="flex flex-wrap gap-3 p-4 bg-gray-800 border border-gray-700 rounded-xl">
130+
{/* Date range */}
131+
<div className="flex flex-col gap-1">
132+
<label className="text-xs text-gray-400 font-medium">Start Date</label>
133+
<input
134+
type="date"
135+
value={filters.startDate}
136+
onChange={(e) => set("startDate", e.target.value)}
137+
className="px-3 py-1.5 bg-gray-900 border border-gray-700 text-white text-sm rounded-lg focus:outline-none focus:border-indigo-500"
138+
aria-label="Start date"
139+
/>
140+
</div>
141+
<div className="flex flex-col gap-1">
142+
<label className="text-xs text-gray-400 font-medium">End Date</label>
143+
<input
144+
type="date"
145+
value={filters.endDate}
146+
onChange={(e) => set("endDate", e.target.value)}
147+
className="px-3 py-1.5 bg-gray-900 border border-gray-700 text-white text-sm rounded-lg focus:outline-none focus:border-indigo-500"
148+
aria-label="End date"
149+
/>
150+
</div>
151+
152+
{/* Outcome filter */}
153+
<div className="flex flex-col gap-1">
154+
<label className="text-xs text-gray-400 font-medium">Outcome</label>
155+
<select
156+
value={filters.outcome}
157+
onChange={(e) => set("outcome", e.target.value)}
158+
className="px-3 py-1.5 bg-gray-900 border border-gray-700 text-white text-sm rounded-lg focus:outline-none focus:border-indigo-500"
159+
aria-label="Outcome filter"
160+
>
161+
{OUTCOMES.map((o) => (
162+
<option key={o} value={o}>
163+
{o}
164+
</option>
165+
))}
166+
</select>
167+
</div>
168+
169+
{/* Category filter */}
170+
<div className="flex flex-col gap-1">
171+
<label className="text-xs text-gray-400 font-medium">Category</label>
172+
<input
173+
type="text"
174+
placeholder="e.g. crypto"
175+
value={filters.category}
176+
onChange={(e) => set("category", e.target.value)}
177+
className="px-3 py-1.5 bg-gray-900 border border-gray-700 text-white text-sm rounded-lg focus:outline-none focus:border-indigo-500 placeholder-gray-600"
178+
aria-label="Market category filter"
179+
/>
180+
</div>
181+
</div>
182+
)}
183+
</div>
184+
);
185+
}
186+
187+
/**
188+
* Hook: manages filter state synced with URL query params.
189+
*/
190+
export function useBetHistoryFilters() {
191+
const router = useRouter();
192+
const pathname = usePathname();
193+
const searchParams = useSearchParams();
194+
195+
const [filters, setFilters] = useState<FilterState>(() => paramsToFilters(searchParams));
196+
197+
// Restore from URL on mount / URL change
198+
useEffect(() => {
199+
setFilters(paramsToFilters(searchParams));
200+
}, [searchParams]);
201+
202+
const onChange = useCallback(
203+
(f: FilterState) => {
204+
setFilters(f);
205+
const params = filtersToParams(f);
206+
const qs = params.toString();
207+
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
208+
},
209+
[router, pathname]
210+
);
211+
212+
const onClear = useCallback(() => {
213+
onChange(DEFAULT_FILTERS);
214+
}, [onChange]);
215+
216+
return { filters, onChange, onClear };
217+
}

frontend/src/components/BetHistoryTable.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useState, useEffect, useCallback } from "react";
99
import Papa from "papaparse";
1010
import EmptyState from "./EmptyState";
1111
import { NoBetsIllustration } from "../assets/emptyStates";
12+
import BetHistoryFilters, { applyFilters, useBetHistoryFilters } from "./BetHistoryFilters";
1213
// ── Types ────────────────────────────────────────────────────────────────────
1314

1415
export interface BetRow {
@@ -100,6 +101,7 @@ export default function BetHistoryTable({ walletAddress, apiUrl = "" }: Props) {
100101
const [sortKey, setSortKey] = useState<SortKey>("date");
101102
const [sortDir, setSortDir] = useState<SortDir>("desc");
102103
const [page, setPage] = useState(0);
104+
const { filters, onChange: onFiltersChange, onClear: onFiltersClear } = useBetHistoryFilters();
103105

104106
useEffect(() => {
105107
if (!walletAddress) return;
@@ -124,10 +126,11 @@ export default function BetHistoryTable({ walletAddress, apiUrl = "" }: Props) {
124126
[sortKey]
125127
);
126128

127-
const sorted = sortBets(allRows, sortKey, sortDir);
129+
const filtered = applyFilters(allRows, filters);
130+
const sorted = sortBets(filtered, sortKey, sortDir);
128131
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
129132
const pageRows = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
130-
const summary = computeSummary(allRows);
133+
const summary = computeSummary(filtered);
131134

132135
const handleDownloadCSV = useCallback(() => {
133136
const csv = Papa.unparse(buildCSVRows(sorted));
@@ -170,6 +173,9 @@ export default function BetHistoryTable({ walletAddress, apiUrl = "" }: Props) {
170173
</button>
171174
</div>
172175

176+
{/* Filter bar */}
177+
<BetHistoryFilters filters={filters} onChange={onFiltersChange} onClear={onFiltersClear} />
178+
173179
{allRows.length === 0 ? (
174180
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-8 text-center">
175181
<EmptyState

0 commit comments

Comments
 (0)