Skip to content

Commit 428ab92

Browse files
authored
Merge pull request #164 from akordavid373/feature/extract-filter-sort-hook
feat: Extract filter/sort logic from HomeScreen to useFilteredSubscri…
2 parents cca3b59 + 2d3aaca commit 428ab92

3 files changed

Lines changed: 326 additions & 2 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import { useFilteredSubscriptions } from '../useFilteredSubscriptions';
3+
import { Subscription, SubscriptionCategory, BillingCycle } from '../../types/subscription';
4+
5+
describe('useFilteredSubscriptions', () => {
6+
const mockSubscriptions: Subscription[] = [
7+
{
8+
id: '1',
9+
name: 'Netflix',
10+
description: 'Streaming service',
11+
category: SubscriptionCategory.STREAMING,
12+
price: 15.99,
13+
currency: 'USD',
14+
billingCycle: BillingCycle.MONTHLY,
15+
nextBillingDate: new Date('2024-02-01'),
16+
isActive: true,
17+
isCryptoEnabled: false,
18+
createdAt: new Date(),
19+
updatedAt: new Date(),
20+
},
21+
{
22+
id: '2',
23+
name: 'Spotify',
24+
description: 'Music streaming',
25+
category: SubscriptionCategory.STREAMING,
26+
price: 9.99,
27+
currency: 'USD',
28+
billingCycle: BillingCycle.MONTHLY,
29+
nextBillingDate: new Date('2024-02-15'),
30+
isActive: true,
31+
isCryptoEnabled: true,
32+
cryptoStreamId: 'stream-123',
33+
createdAt: new Date(),
34+
updatedAt: new Date(),
35+
},
36+
{
37+
id: '3',
38+
name: 'Adobe Creative Cloud',
39+
description: 'Design software',
40+
category: SubscriptionCategory.SOFTWARE,
41+
price: 599.99,
42+
currency: 'USD',
43+
billingCycle: BillingCycle.YEARLY,
44+
nextBillingDate: new Date('2024-03-01'),
45+
isActive: false,
46+
isCryptoEnabled: false,
47+
createdAt: new Date(),
48+
updatedAt: new Date(),
49+
},
50+
];
51+
52+
it('should return all subscriptions when no filters are applied', () => {
53+
const { result } = renderHook(() => useFilteredSubscriptions(mockSubscriptions));
54+
55+
expect(result.current.filteredAndSorted).toHaveLength(3);
56+
expect(result.current.hasActiveFilters).toBe(false);
57+
expect(result.current.activeFilterCount).toBe(0);
58+
});
59+
60+
it('should filter by search query', () => {
61+
const { result } = renderHook(() => useFilteredSubscriptions(mockSubscriptions));
62+
63+
result.current.filters.setSearchQuery('Netflix');
64+
65+
expect(result.current.filteredAndSorted).toHaveLength(1);
66+
expect(result.current.filteredAndSorted[0].name).toBe('Netflix');
67+
expect(result.current.hasActiveFilters).toBe(true);
68+
expect(result.current.activeFilterCount).toBe(1);
69+
});
70+
71+
it('should filter by category', () => {
72+
const { result } = renderHook(() => useFilteredSubscriptions(mockSubscriptions));
73+
74+
result.current.filters.setSelectedCategories([SubscriptionCategory.STREAMING]);
75+
76+
expect(result.current.filteredAndSorted).toHaveLength(2);
77+
expect(result.current.filteredAndSorted.every(sub => sub.category === SubscriptionCategory.STREAMING)).toBe(true);
78+
expect(result.current.hasActiveFilters).toBe(true);
79+
});
80+
81+
it('should filter by active status', () => {
82+
const { result } = renderHook(() => useFilteredSubscriptions(mockSubscriptions));
83+
84+
result.current.filters.setShowActiveOnly(true);
85+
86+
expect(result.current.filteredAndSorted).toHaveLength(2);
87+
expect(result.current.filteredAndSorted.every(sub => sub.isActive)).toBe(true);
88+
});
89+
90+
it('should sort by name', () => {
91+
const { result } = renderHook(() => useFilteredSubscriptions(mockSubscriptions));
92+
93+
result.current.filters.setSortBy('name');
94+
result.current.filters.setSortOrder('asc');
95+
96+
const names = result.current.filteredAndSorted.map(sub => sub.name);
97+
expect(names).toEqual(['Adobe Creative Cloud', 'Netflix', 'Spotify']);
98+
});
99+
100+
it('should sort by price', () => {
101+
const { result } = renderHook(() => useFilteredSubscriptions(mockSubscriptions));
102+
103+
result.current.filters.setSortBy('price');
104+
result.current.filters.setSortOrder('asc');
105+
106+
const prices = result.current.filteredAndSorted.map(sub => sub.price);
107+
expect(prices).toEqual([9.99, 15.99, 599.99]);
108+
});
109+
110+
it('should clear all filters', () => {
111+
const { result } = renderHook(() => useFilteredSubscriptions(mockSubscriptions));
112+
113+
// Apply some filters
114+
result.current.filters.setSearchQuery('Netflix');
115+
result.current.filters.setSelectedCategories([SubscriptionCategory.STREAMING]);
116+
result.current.filters.setShowActiveOnly(false);
117+
118+
expect(result.current.hasActiveFilters).toBe(true);
119+
120+
// Clear all filters
121+
result.current.clearAllFilters();
122+
123+
expect(result.current.filteredAndSorted).toHaveLength(3);
124+
expect(result.current.hasActiveFilters).toBe(false);
125+
expect(result.current.activeFilterCount).toBe(0);
126+
expect(result.current.filters.searchQuery).toBe('');
127+
expect(result.current.filters.selectedCategories).toEqual([]);
128+
expect(result.current.filters.showActiveOnly).toBe(true);
129+
});
130+
131+
it('should handle empty subscriptions array', () => {
132+
const { result } = renderHook(() => useFilteredSubscriptions([]));
133+
134+
expect(result.current.filteredAndSorted).toHaveLength(0);
135+
expect(result.current.hasActiveFilters).toBe(false);
136+
});
137+
138+
it('should handle null/undefined subscriptions', () => {
139+
const { result } = renderHook(() => useFilteredSubscriptions(null as any));
140+
141+
expect(result.current.filteredAndSorted).toHaveLength(0);
142+
});
143+
});
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { useState, useMemo, useCallback } from 'react';
2+
import { Subscription, SubscriptionCategory, BillingCycle } from '../types/subscription';
3+
4+
export const useFilteredSubscriptions = (subscriptions: Subscription[]) => {
5+
const [searchQuery, setSearchQuery] = useState('');
6+
const [selectedCategories, setSelectedCategories] = useState<SubscriptionCategory[]>([]);
7+
const [selectedBillingCycles, setSelectedBillingCycles] = useState<BillingCycle[]>([]);
8+
const [priceRange, setPriceRange] = useState({ min: 0, max: 1000 });
9+
const [showActiveOnly, setShowActiveOnly] = useState(true);
10+
const [showCryptoOnly, setShowCryptoOnly] = useState(false);
11+
const [sortBy, setSortBy] = useState<'name' | 'price' | 'nextBilling' | 'category'>('name');
12+
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
13+
14+
const normalizedSearch = useMemo(() => searchQuery.trim().toLowerCase(), [searchQuery]);
15+
16+
const matchesSearch = useCallback(
17+
(sub: Subscription): boolean => {
18+
if (!normalizedSearch) return true;
19+
return (
20+
sub.name.toLowerCase().includes(normalizedSearch) ||
21+
sub.description?.toLowerCase().includes(normalizedSearch) === true
22+
);
23+
},
24+
[normalizedSearch]
25+
);
26+
27+
const matchesCategory = useCallback(
28+
(sub: Subscription): boolean => {
29+
if (selectedCategories.length === 0) return true;
30+
return selectedCategories.includes(sub.category);
31+
},
32+
[selectedCategories]
33+
);
34+
35+
const matchesBillingCycle = useCallback(
36+
(sub: Subscription): boolean => {
37+
if (selectedBillingCycles.length === 0) return true;
38+
return selectedBillingCycles.includes(sub.billingCycle);
39+
},
40+
[selectedBillingCycles]
41+
);
42+
43+
const matchesPriceRange = useCallback(
44+
(sub: Subscription): boolean => sub.price >= priceRange.min && sub.price <= priceRange.max,
45+
[priceRange.max, priceRange.min]
46+
);
47+
48+
const matchesActiveOnly = useCallback(
49+
(sub: Subscription): boolean => !showActiveOnly || sub.isActive,
50+
[showActiveOnly]
51+
);
52+
53+
const matchesCryptoOnly = useCallback(
54+
(sub: Subscription): boolean => !showCryptoOnly || sub.isCryptoEnabled,
55+
[showCryptoOnly]
56+
);
57+
58+
const comparator = useCallback(
59+
(a: Subscription, b: Subscription): number => {
60+
let comp = 0;
61+
switch (sortBy) {
62+
case 'name':
63+
comp = a.name.localeCompare(b.name);
64+
break;
65+
case 'price':
66+
comp = a.price - b.price;
67+
break;
68+
case 'nextBilling':
69+
comp = new Date(a.nextBillingDate).getTime() - new Date(b.nextBillingDate).getTime();
70+
break;
71+
case 'category':
72+
comp = a.category.localeCompare(b.category);
73+
break;
74+
}
75+
return sortOrder === 'asc' ? comp : -comp;
76+
},
77+
[sortBy, sortOrder]
78+
);
79+
80+
const searchedSubscriptions = useMemo(
81+
() => (subscriptions || []).filter(matchesSearch),
82+
[subscriptions, matchesSearch]
83+
);
84+
85+
const categoryFilteredSubscriptions = useMemo(
86+
() => searchedSubscriptions.filter(matchesCategory),
87+
[searchedSubscriptions, matchesCategory]
88+
);
89+
90+
const billingCycleFilteredSubscriptions = useMemo(
91+
() => categoryFilteredSubscriptions.filter(matchesBillingCycle),
92+
[categoryFilteredSubscriptions, matchesBillingCycle]
93+
);
94+
95+
const priceFilteredSubscriptions = useMemo(
96+
() => billingCycleFilteredSubscriptions.filter(matchesPriceRange),
97+
[billingCycleFilteredSubscriptions, matchesPriceRange]
98+
);
99+
100+
const activeFilteredSubscriptions = useMemo(
101+
() => priceFilteredSubscriptions.filter(matchesActiveOnly),
102+
[priceFilteredSubscriptions, matchesActiveOnly]
103+
);
104+
105+
const cryptoFilteredSubscriptions = useMemo(
106+
() => activeFilteredSubscriptions.filter(matchesCryptoOnly),
107+
[activeFilteredSubscriptions, matchesCryptoOnly]
108+
);
109+
110+
const filteredAndSorted = useMemo(
111+
() => [...cryptoFilteredSubscriptions].sort(comparator),
112+
[cryptoFilteredSubscriptions, comparator]
113+
);
114+
115+
const clearAllFilters = useCallback(() => {
116+
setSearchQuery('');
117+
setSelectedCategories([]);
118+
setSelectedBillingCycles([]);
119+
setPriceRange({ min: 0, max: 1000 });
120+
setShowActiveOnly(true);
121+
setShowCryptoOnly(false);
122+
setSortBy('name');
123+
setSortOrder('asc');
124+
}, []);
125+
126+
// Basic in-dev profiling: logs expensive filter passes on large lists.
127+
useMemo(() => {
128+
if (__DEV__ && (subscriptions?.length ?? 0) >= 200) {
129+
console.debug('[useFilteredSubscriptions] recalculated', {
130+
total: subscriptions.length,
131+
filtered: filteredAndSorted.length,
132+
});
133+
}
134+
}, [subscriptions, filteredAndSorted.length]);
135+
136+
const activeFilterCount = useMemo(() => {
137+
let count = 0;
138+
if (searchQuery.trim()) count++;
139+
if (selectedCategories.length > 0) count++;
140+
if (selectedBillingCycles.length > 0) count++;
141+
if (priceRange.min > 0 || priceRange.max < 1000) count++;
142+
if (!showActiveOnly) count++;
143+
if (showCryptoOnly) count++;
144+
if (sortBy !== 'name' || sortOrder !== 'asc') count++;
145+
return count;
146+
}, [
147+
searchQuery,
148+
selectedCategories,
149+
selectedBillingCycles,
150+
priceRange,
151+
showActiveOnly,
152+
showCryptoOnly,
153+
sortBy,
154+
sortOrder,
155+
]);
156+
157+
return {
158+
filters: {
159+
searchQuery,
160+
setSearchQuery,
161+
selectedCategories,
162+
setSelectedCategories,
163+
selectedBillingCycles,
164+
setSelectedBillingCycles,
165+
priceRange,
166+
setPriceRange,
167+
showActiveOnly,
168+
setShowActiveOnly,
169+
showCryptoOnly,
170+
setShowCryptoOnly,
171+
sortBy,
172+
setSortBy,
173+
sortOrder,
174+
setSortOrder,
175+
},
176+
filteredAndSorted,
177+
activeFilterCount,
178+
hasActiveFilters: activeFilterCount > 0,
179+
clearAllFilters,
180+
};
181+
};

src/screens/HomeScreen.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { RootStackParamList } from '../navigation/types';
1111

1212
// Components
1313
import { FloatingActionButton } from '../components/common/FloatingActionButton';
14-
import { useSubscriptionFilters } from '../hooks/useSubscriptionFilters';
14+
import { useFilteredSubscriptions } from '../hooks/useFilteredSubscriptions';
1515
import { FilterBar } from '../components/home/FilterBar';
1616
import { FilterModal } from '../components/home/FilterModal';
1717
import { StatsCard } from '../components/home/StatsCard';
@@ -28,7 +28,7 @@ const HomeScreen: React.FC = () => {
2828

2929
// Use the new hook
3030
const { filters, filteredAndSorted, activeFilterCount, hasActiveFilters, clearAllFilters } =
31-
useSubscriptionFilters(subscriptions);
31+
useFilteredSubscriptions(subscriptions);
3232
const [showFilterModal, setShowFilterModal] = useState(false);
3333

3434
useEffect(() => {

0 commit comments

Comments
 (0)