From 9043e51da5417a00a9ae910d04b715877e81981f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Max-Ow=C3=B3lab=C3=AD?= Date: Tue, 23 Jun 2026 01:57:26 +0100 Subject: [PATCH] Implement advanced Elasticsearch subscription search with faceted filters, fuzzy matching, highlights, and saved search notifications. Closes #392 Co-authored-by: Cursor --- app/screens/AdvancedSearchScreen.tsx | 490 ++++++++++++++++-- app/services/__tests__/searchService.test.ts | 105 ++++ app/services/searchService.ts | 189 ++++--- app/stores/searchStore.ts | 198 ++++--- backend/elasticsearch/config.ts | 15 +- backend/services/index.ts | 6 +- .../services/search/ElasticsearchService.ts | 453 ++++++++++++++++ .../__tests__/ElasticsearchService.test.ts | 46 ++ .../subscription/ElasticsearchService.ts | 326 +----------- .../__tests__/ElasticsearchService.test.ts | 68 ++- backend/services/subscription/index.ts | 10 +- backend/services/subscription/interfaces.ts | 11 + src/hooks/useElasticsearchSearch.ts | 4 +- src/navigation/AppNavigator.tsx | 10 + src/navigation/types.ts | 1 + src/screens/SettingsScreen.tsx | 19 + src/store/subscriptionStore.ts | 4 + src/types/subscription.ts | 8 + 18 files changed, 1450 insertions(+), 513 deletions(-) create mode 100644 app/services/__tests__/searchService.test.ts create mode 100644 backend/services/search/ElasticsearchService.ts create mode 100644 backend/services/search/__tests__/ElasticsearchService.test.ts diff --git a/app/screens/AdvancedSearchScreen.tsx b/app/screens/AdvancedSearchScreen.tsx index 348df2c3..772d5715 100644 --- a/app/screens/AdvancedSearchScreen.tsx +++ b/app/screens/AdvancedSearchScreen.tsx @@ -1,67 +1,449 @@ -import React, { useMemo, useState } from 'react'; -import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native'; -import { Subscription } from '../types/subscription'; -import { search_subscriptions, SavedSearch, SearchQuery } from '../services/searchService'; -import { useSubscriptionStore } from '../store/subscriptionStore'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + View, + Text, + TextInput, + FlatList, + StyleSheet, + SafeAreaView, + TouchableOpacity, + Switch, + ScrollView, + Alert, + ActivityIndicator, +} from 'react-native'; +import { Subscription, SubscriptionCategory, BillingCycle } from '../../src/types/subscription'; +import { useSubscriptionStore } from '../../src/store/subscriptionStore'; import { useSearchStore } from '../stores/searchStore'; +import { + formatHighlight, + getBillingCycleLabel, + getCategoryLabel, + hasHighlightMatch, +} from '../services/searchService'; +import { colors, spacing, typography, borderRadius } from '../../src/utils/constants'; +import { SearchHit } from '../../backend/services/search/ElasticsearchService'; -const styles = StyleSheet.create({ - container: { flex: 1, padding: 16 }, - input: { height: 40, borderColor: '#ccc', borderWidth: 1, paddingHorizontal: 8, borderRadius: 4 }, - item: { paddingVertical: 8, borderBottomWidth: 1, borderColor: '#eee' }, - title: { fontSize: 16, fontWeight: '600' }, - subtitle: { fontSize: 12, color: '#666' }, - header: { fontSize: 18, fontWeight: '700', marginVertical: 8 }, -}); +const STATUS_OPTIONS: Array<'active' | 'inactive'> = ['active', 'inactive']; export const AdvancedSearchScreen: React.FC = () => { - const [query, setQuery] = useState(''); - const { subscriptions } = useSubscriptionStore.getState(); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - - const runSearch = () => { - setLoading(true); - // Lightweight synchronous search; in a real app this could be async if hitting API - const result = search_subscriptions({ query, filters: {} }); - setResults(result.subscriptions); - setLoading(false); - }; - - // Initial populate with all subscriptions for UX in absence of a query - const initialAll = useMemo(() => subscriptions, [subscriptions]); - - React.useEffect(() => { - if (initialAll?.length) { - setResults(initialAll); + const subscriptions = useSubscriptionStore((s) => s.subscriptions); + const { + queryText, + filters, + sort, + result, + savedSearches, + loading, + setQueryText, + setFilters, + setSort, + runSearch, + saveCurrentSearch, + loadSavedSearch, + removeSavedSearch, + checkNotifications, + hydrateSavedSearches, + clear, + } = useSearchStore(); + + const [showFilters, setShowFilters] = useState(false); + const [saveName, setSaveName] = useState(''); + const [notifyOnMatch, setNotifyOnMatch] = useState(true); + const [minPrice, setMinPrice] = useState(''); + const [maxPrice, setMaxPrice] = useState(''); + + useEffect(() => { + hydrateSavedSearches().then(() => runSearch()); + }, [hydrateSavedSearches, runSearch]); + + useEffect(() => { + runSearch(); + }, [subscriptions, runSearch]); + + useEffect(() => { + const notifications = checkNotifications(); + for (const note of notifications) { + Alert.alert( + 'Saved search match', + `"${note.savedSearchName}" has ${note.newMatchCount} new match(es).` + ); } - }, [initialAll]); + }, [subscriptions, checkNotifications]); + + const toggleCategory = useCallback( + (category: SubscriptionCategory) => { + const current = filters.categories ?? []; + const next = current.includes(category) + ? current.filter((c) => c !== category) + : [...current, category]; + setFilters({ categories: next }); + }, + [filters.categories, setFilters] + ); + + const toggleBillingCycle = useCallback( + (cycle: BillingCycle) => { + const current = filters.billingCycles ?? []; + const next = current.includes(cycle) + ? current.filter((c) => c !== cycle) + : [...current, cycle]; + setFilters({ billingCycles: next }); + }, + [filters.billingCycles, setFilters] + ); + + const toggleStatus = useCallback( + (status: 'active' | 'inactive') => { + const current = filters.statuses ?? []; + const next = current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status]; + setFilters({ statuses: next }); + }, + [filters.statuses, setFilters] + ); + + const applyPriceRange = useCallback(() => { + const min = minPrice.trim() ? Number(minPrice) : 0; + const max = maxPrice.trim() ? Number(maxPrice) : Number.MAX_SAFE_INTEGER; + setFilters({ priceRange: { min, max } }); + }, [maxPrice, minPrice, setFilters]); + + const handleSaveSearch = useCallback(async () => { + if (!saveName.trim()) { + Alert.alert('Name required', 'Enter a name for this saved search.'); + return; + } + await saveCurrentSearch(saveName.trim(), notifyOnMatch); + setSaveName(''); + Alert.alert('Saved', 'Search saved. You will be notified when new matches appear.'); + }, [notifyOnMatch, saveCurrentSearch, saveName]); + + const hits = result?.hits ?? []; + const facets = result?.facets; + + const renderHit = useCallback(({ item }: { item: SearchHit }) => { + const sub = item.subscription; + const titleHighlight = item.highlights.planName ?? item.highlights.name; + const subtitleHighlight = + item.highlights.customerName ?? + item.highlights.customerEmail ?? + item.highlights.notes ?? + item.highlights.description; + + return ( + + + {hasHighlightMatch(titleHighlight) + ? formatHighlight(titleHighlight, sub.name) + : sub.planName ?? sub.name} + + {(sub.customerName || sub.customerEmail) && ( + + {[sub.customerName, sub.customerEmail].filter(Boolean).join(' · ')} + + )} + {subtitleHighlight ? ( + + {formatHighlight(subtitleHighlight, sub.notes ?? sub.description ?? '')} + + ) : null} + + {getCategoryLabel(sub.category)} · {sub.currency} {sub.price.toFixed(2)} /{' '} + {getBillingCycleLabel(sub.billingCycle)} · {sub.isActive ? 'Active' : 'Inactive'} + + {queryText.trim() ? Score: {item.score.toFixed(1)} : null} + + ); + }, [queryText]); + + const facetSummary = useMemo(() => { + if (!facets) return ''; + return `${facets.activeCount} active · ${facets.cryptoCount} crypto · ${result?.total ?? 0} results`; + }, [facets, result?.total]); return ( - - Advanced Subscription Search - -