diff --git a/frontend/components/ProposalList.tsx b/frontend/components/ProposalList.tsx index 7144a948..795a1bae 100644 --- a/frontend/components/ProposalList.tsx +++ b/frontend/components/ProposalList.tsx @@ -463,6 +463,9 @@ export default function ProposalList({ userAddress }: ProposalListProps) { const totalVotesB = b.votesFor + b.votesAgainst; return totalVotesB - totalVotesA; } + case 'ending-soon': + if (a.executed !== b.executed) return a.executed ? 1 : -1; + return a.createdAt - b.createdAt; default: return 0; } @@ -511,6 +514,18 @@ export default function ProposalList({ userAddress }: ProposalListProps) { + {filterParams.sort !== 'newest' && ( +

+ {{ + 'oldest': 'Sorted by earliest created', + 'ending-soon': 'Sorted by active proposals closest to their deadline', + 'highest': 'Sorted by largest funding request', + 'lowest': 'Sorted by smallest funding request', + 'most-votes': 'Sorted by highest total vote count', + }[filterParams.sort]} +

+ )} +
{sortedProposals.length === 0 && filterParams.q ? (
diff --git a/frontend/components/ProposalSortbar.tsx b/frontend/components/ProposalSortbar.tsx index f89654fe..96dd7965 100644 --- a/frontend/components/ProposalSortbar.tsx +++ b/frontend/components/ProposalSortbar.tsx @@ -15,6 +15,7 @@ interface ProposalSortbarProps { const DEFAULT_SORT_OPTIONS: SortOption[] = [ { label: 'Date Created', value: 'createdAt' }, + { label: 'Ending Soon', value: 'endingSoon' }, { label: 'Title', value: 'title' }, { label: 'Funding Requested', value: 'requestedAmount' }, { label: 'Vote Count', value: 'votes' }, diff --git a/frontend/components/SortDropdown.tsx b/frontend/components/SortDropdown.tsx index 99c0972b..b8d3abeb 100644 --- a/frontend/components/SortDropdown.tsx +++ b/frontend/components/SortDropdown.tsx @@ -1,89 +1,180 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; - -export type SortOption = 'newest' | 'oldest' | 'highest' | 'lowest' | 'most-votes'; +import { useState, useRef, useEffect, useCallback } from 'react'; +import type { SortOption } from '../src/lib/proposal-params'; interface SortDropdownProps { onSortChange: (sort: SortOption) => void; sort?: SortOption; } +const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: 'newest', label: 'Newest First' }, + { value: 'oldest', label: 'Oldest First' }, + { value: 'ending-soon', label: 'Ending Soon' }, + { value: 'highest', label: 'Highest Amount' }, + { value: 'lowest', label: 'Lowest Amount' }, + { value: 'most-votes', label: 'Most Votes' }, +]; + +const SORT_DESCRIPTIONS: Record = { + newest: 'Most recently created proposals first', + oldest: 'Earliest created proposals first', + 'ending-soon': 'Active proposals closest to their deadline', + highest: 'Largest funding requests first', + lowest: 'Smallest funding requests first', + 'most-votes': 'Proposals with the most total votes', +}; + export default function SortDropdown({ onSortChange, sort }: SortDropdownProps) { const [isOpen, setIsOpen] = useState(false); const [internalSort, setInternalSort] = useState('newest'); + const [activeIndex, setActiveIndex] = useState(-1); + const dropdownRef = useRef(null); + const triggerRef = useRef(null); const selectedSort = sort ?? internalSort; - const sortOptions = [ - { value: 'newest' as const, label: 'Newest First' }, - { value: 'oldest' as const, label: 'Oldest First' }, - { value: 'highest' as const, label: 'Highest Amount' }, - { value: 'lowest' as const, label: 'Lowest Amount' }, - { value: 'most-votes' as const, label: 'Most Votes' }, - ]; + const close = useCallback(() => { + setIsOpen(false); + setActiveIndex(-1); + }, []); + + useEffect(() => { + if (isOpen) { + const index = SORT_OPTIONS.findIndex(o => o.value === selectedSort); + setActiveIndex(index >= 0 ? index : 0); + } + }, [isOpen, selectedSort]); - // Click outside to close useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); + close(); } }; - document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + }, [close]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + close(); + triggerRef.current?.focus(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + setActiveIndex(prev => (prev < SORT_OPTIONS.length - 1 ? prev + 1 : 0)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + setActiveIndex(prev => (prev > 0 ? prev - 1 : SORT_OPTIONS.length - 1)); + } else if (event.key === 'Enter') { + event.preventDefault(); + setActiveIndex(prev => { + const opt = SORT_OPTIONS[prev]; + if (opt) { + if (sort === undefined) setInternalSort(opt.value); + onSortChange(opt.value); + close(); + triggerRef.current?.focus(); + } + return prev; + }); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, close, onSortChange, sort]); const handleSortSelect = (s: SortOption) => { if (sort === undefined) setInternalSort(s); onSortChange(s); - setIsOpen(false); + close(); }; - const selectedLabel = sortOptions.find(s => s.value === selectedSort)?.label || 'Newest First'; + const selectedLabel = SORT_OPTIONS.find(s => s.value === selectedSort)?.label ?? 'Newest First'; return (
+ {selectedSort !== 'newest' && ( +

+ {SORT_DESCRIPTIONS[selectedSort]} +

+ )} + {isOpen && ( -
- {sortOptions.map((option) => ( - - ))} -
+
    + {SORT_OPTIONS.map((option, index) => { + const isSelected = selectedSort === option.value; + const isActive = activeIndex === index; + + return ( +
  • + +
  • + ); + })} +
)}
); diff --git a/frontend/src/lib/proposal-params.test.ts b/frontend/src/lib/proposal-params.test.ts index 02418dc3..eeeb9adf 100644 --- a/frontend/src/lib/proposal-params.test.ts +++ b/frontend/src/lib/proposal-params.test.ts @@ -53,10 +53,14 @@ describe('parseSort', () => { }); it('accepts all valid sort options', () => { - const valid = ['newest', 'oldest', 'highest', 'lowest', 'most-votes'] as const; + const valid = ['newest', 'oldest', 'highest', 'lowest', 'most-votes', 'ending-soon'] as const; valid.forEach((s) => expect(parseSort(s)).toBe(s)); }); + it('accepts ending-soon as a valid sort value', () => { + expect(parseSort('ending-soon')).toBe('ending-soon'); + }); + it('returns default for unknown value', () => { expect(parseSort('alphabetical')).toBe('newest'); expect(parseSort('')).toBe('newest'); @@ -139,6 +143,11 @@ describe('serializeParams', () => { expect(result.get('sort')).toBe('most-votes'); }); + it('serializes ending-soon sort to query string', () => { + const result = serializeParams({ ...DEFAULT_PARAMS, sort: 'ending-soon' }); + expect(result.get('sort')).toBe('ending-soon'); + }); + it('includes non-default search query', () => { const result = serializeParams({ ...DEFAULT_PARAMS, q: 'treasury' }); expect(result.get('q')).toBe('treasury'); @@ -171,6 +180,20 @@ describe('serializeParams', () => { const parsed = parseSearchParams(serialized); expect(parsed).toEqual(original); }); + + it('round-trips ending-soon sort through parse and serialize', () => { + const original: ProposalFilterParams = { + status: 'active', + category: 'all', + sort: 'ending-soon', + q: '', + page: 1, + }; + const serialized = serializeParams(original); + const parsed = parseSearchParams(serialized); + expect(parsed.sort).toBe('ending-soon'); + expect(parsed.status).toBe('active'); + }); }); describe('buildProposalUrl', () => { @@ -213,6 +236,10 @@ describe('countActiveFilters', () => { expect(countActiveFilters({ ...DEFAULT_PARAMS, sort: 'oldest' })).toBe(1); }); + it('counts ending-soon as an active sort filter', () => { + expect(countActiveFilters({ ...DEFAULT_PARAMS, sort: 'ending-soon' })).toBe(1); + }); + it('counts search query', () => { expect(countActiveFilters({ ...DEFAULT_PARAMS, q: 'hello' })).toBe(1); }); @@ -254,6 +281,10 @@ describe('isDefaultParams', () => { expect(isDefaultParams({ ...DEFAULT_PARAMS, sort: 'oldest' })).toBe(false); }); + it('returns false when sort is ending-soon', () => { + expect(isDefaultParams({ ...DEFAULT_PARAMS, sort: 'ending-soon' })).toBe(false); + }); + it('returns false when q is set', () => { expect(isDefaultParams({ ...DEFAULT_PARAMS, q: 'governance' })).toBe(false); }); diff --git a/frontend/src/lib/proposal-params.ts b/frontend/src/lib/proposal-params.ts index 01ff6c0c..91963bb6 100644 --- a/frontend/src/lib/proposal-params.ts +++ b/frontend/src/lib/proposal-params.ts @@ -7,7 +7,7 @@ export type CategoryFilter = | 'community' | 'research' | 'other'; -export type SortOption = 'newest' | 'oldest' | 'highest' | 'lowest' | 'most-votes'; +export type SortOption = 'newest' | 'oldest' | 'highest' | 'lowest' | 'most-votes' | 'ending-soon'; export interface ProposalFilterParams { status: StatusFilter; @@ -45,6 +45,7 @@ const VALID_SORTS = new Set([ 'highest', 'lowest', 'most-votes', + 'ending-soon', ]); export function parseStatus(value: string | null): StatusFilter { diff --git a/frontend/src/lib/proposal-utils.test.ts b/frontend/src/lib/proposal-utils.test.ts index 86fd2000..9579b716 100644 --- a/frontend/src/lib/proposal-utils.test.ts +++ b/frontend/src/lib/proposal-utils.test.ts @@ -132,12 +132,55 @@ describe('Proposal utilities', () => { expect(sorted[0].createdAt).toBeLessThanOrEqual(sorted[1].createdAt); }); + it('sorts by highest amount', () => { + const low = { ...mockProposal, id: 1, amount: 500 }; + const high = { ...mockProposal, id: 2, amount: 5000 }; + const sorted = sortProposals([low, high], 'highest'); + expect(sorted[0].amount).toBeGreaterThan(sorted[1].amount); + }); + + it('sorts by lowest amount', () => { + const low = { ...mockProposal, id: 1, amount: 500 }; + const high = { ...mockProposal, id: 2, amount: 5000 }; + const sorted = sortProposals([high, low], 'lowest'); + expect(sorted[0].amount).toBeLessThan(sorted[1].amount); + }); + + it('sorts by highest amount with equal amounts', () => { + const p1 = { ...mockProposal, id: 1, amount: 1000 }; + const p2 = { ...mockProposal, id: 2, amount: 1000 }; + const sorted = sortProposals([p1, p2], 'highest'); + expect(sorted[0].amount).toEqual(sorted[1].amount); + }); + it('sorts by most votes', () => { const p1 = { ...mockProposal, votesFor: 10, votesAgainst: 5 }; const p2 = { ...mockProposal, id: 2, votesFor: 50, votesAgainst: 20 }; const sorted = sortProposals([p1, p2], 'most-votes'); expect(calculateTotalVotes(sorted[0])).toBeGreaterThan(calculateTotalVotes(sorted[1])); }); + + it('ending-soon: places active proposals before executed ones', () => { + const active = { ...mockProposal, id: 1, executed: false, createdAt: 1000 }; + const executed = { ...mockProposal, id: 2, executed: true, createdAt: 500 }; + const sorted = sortProposals([executed, active], 'ending-soon'); + expect(sorted[0].executed).toBe(false); + expect(sorted[1].executed).toBe(true); + }); + + it('ending-soon: orders active proposals by ascending createdAt', () => { + const older = { ...mockProposal, id: 1, executed: false, createdAt: 100 }; + const newer = { ...mockProposal, id: 2, executed: false, createdAt: 500 }; + const sorted = sortProposals([newer, older], 'ending-soon'); + expect(sorted[0].createdAt).toBeLessThan(sorted[1].createdAt); + }); + + it('ending-soon: sorts executed proposals by ascending createdAt when all are executed', () => { + const early = { ...mockProposal, id: 1, executed: true, createdAt: 200 }; + const late = { ...mockProposal, id: 2, executed: true, createdAt: 800 }; + const sorted = sortProposals([late, early], 'ending-soon'); + expect(sorted[0].createdAt).toBeLessThan(sorted[1].createdAt); + }); }); describe('findProposal', () => { diff --git a/frontend/src/lib/proposal-utils.ts b/frontend/src/lib/proposal-utils.ts index 7b646101..d7db8ac3 100644 --- a/frontend/src/lib/proposal-utils.ts +++ b/frontend/src/lib/proposal-utils.ts @@ -75,7 +75,7 @@ export function filterProposalsByStatus( */ export function sortProposals( proposals: Proposal[], - sortBy: 'newest' | 'oldest' | 'highest' | 'lowest' | 'most-votes' = 'newest', + sortBy: 'newest' | 'oldest' | 'highest' | 'lowest' | 'most-votes' | 'ending-soon' = 'newest', ): Proposal[] { const sorted = [...proposals]; @@ -90,6 +90,11 @@ export function sortProposals( return sorted.sort((a, b) => a.amount - b.amount); case 'most-votes': return sorted.sort((a, b) => calculateTotalVotes(b) - calculateTotalVotes(a)); + case 'ending-soon': + return sorted.sort((a, b) => { + if (a.executed !== b.executed) return a.executed ? 1 : -1; + return a.createdAt - b.createdAt; + }); default: return sorted; }