Skip to content

Commit 9043e51

Browse files
Implement advanced Elasticsearch subscription search with faceted filters, fuzzy matching, highlights, and saved search notifications.
Closes #392 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent c979f17 commit 9043e51

18 files changed

Lines changed: 1450 additions & 513 deletions

File tree

app/screens/AdvancedSearchScreen.tsx

Lines changed: 436 additions & 54 deletions
Large diffs are not rendered by default.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {
2+
ElasticsearchService,
3+
elasticsearchService,
4+
} from '../../../backend/services/search/ElasticsearchService';
5+
import {
6+
Subscription,
7+
SubscriptionCategory,
8+
BillingCycle,
9+
} from '../../../src/types/subscription';
10+
11+
jest.mock('@react-native-async-storage/async-storage', () =>
12+
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
13+
);
14+
15+
jest.mock('../../../src/store/subscriptionStore', () => ({
16+
useSubscriptionStore: {
17+
getState: () => ({
18+
subscriptions: [
19+
{
20+
id: '1',
21+
name: 'Netflix',
22+
planName: 'Netflix Premium',
23+
customerName: 'Jane Doe',
24+
customerEmail: 'jane@example.com',
25+
notes: 'auto-renew',
26+
category: SubscriptionCategory.STREAMING,
27+
price: 15.99,
28+
currency: 'USD',
29+
billingCycle: BillingCycle.MONTHLY,
30+
nextBillingDate: new Date('2026-05-01'),
31+
isActive: true,
32+
isCryptoEnabled: false,
33+
createdAt: new Date('2026-01-01'),
34+
updatedAt: new Date('2026-01-01'),
35+
},
36+
],
37+
}),
38+
},
39+
}));
40+
41+
describe('searchService', () => {
42+
beforeEach(() => {
43+
elasticsearchService.bulkIndex([]);
44+
});
45+
46+
it('delegates full-text search to ElasticsearchService', async () => {
47+
const { search_subscriptions } = require('../searchService');
48+
const result = search_subscriptions({ query: 'Jane' });
49+
expect(result.total).toBe(1);
50+
expect(result.hits[0].subscription.customerName).toBe('Jane Doe');
51+
});
52+
53+
it('returns suggestions for partial queries', () => {
54+
const { get_search_suggestions } = require('../searchService');
55+
const suggestions = get_search_suggestions('net');
56+
expect(suggestions.length).toBeGreaterThan(0);
57+
});
58+
59+
it('persists saved searches', async () => {
60+
const { save_search, load_saved_searches } = require('../searchService');
61+
await save_search({
62+
id: 'saved-1',
63+
name: 'VIP',
64+
query: { query: 'Jane' },
65+
notifyOnNewMatches: true,
66+
createdAt: Date.now(),
67+
});
68+
const saved = await load_saved_searches();
69+
expect(saved.some((item: { id: string }) => item.id === 'saved-1')).toBe(true);
70+
});
71+
});
72+
73+
describe('ElasticsearchService saved search notifications', () => {
74+
it('notifies when match count increases', () => {
75+
const service = new ElasticsearchService();
76+
const sub: Subscription = {
77+
id: '1',
78+
name: 'Acme',
79+
category: SubscriptionCategory.SOFTWARE,
80+
price: 10,
81+
currency: 'USD',
82+
billingCycle: BillingCycle.MONTHLY,
83+
nextBillingDate: new Date(),
84+
isActive: true,
85+
isCryptoEnabled: false,
86+
createdAt: new Date(),
87+
updatedAt: new Date(),
88+
};
89+
90+
service.bulkIndex([]);
91+
service.registerSavedSearch({
92+
id: 'saved-2',
93+
name: 'Acme matches',
94+
query: { query: 'Acme' },
95+
notifyOnNewMatches: true,
96+
lastMatchCount: 0,
97+
createdAt: Date.now(),
98+
});
99+
100+
expect(service.checkSavedSearchNotifications()).toEqual([]);
101+
service.indexDocument(sub);
102+
const notifications = service.checkSavedSearchNotifications();
103+
expect(notifications[0].newMatchCount).toBe(1);
104+
});
105+
});

app/services/searchService.ts

Lines changed: 115 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,129 @@
1-
import { Subscription, SubscriptionCategory } from '../types/subscription';
2-
import { useSubscriptionStore } from '../store/subscriptionStore';
3-
import { currencyService } from './currencyService';
4-
import { useSettingsStore } from '../store/settingsStore';
5-
6-
export type SearchQuery = {
7-
query: string;
8-
filters?: {
9-
category?: SubscriptionCategory | string;
10-
minPrice?: number;
11-
maxPrice?: number;
12-
activeOnly?: boolean;
13-
};
14-
};
1+
import AsyncStorage from '@react-native-async-storage/async-storage';
2+
import {
3+
Subscription,
4+
SubscriptionCategory,
5+
BillingCycle,
6+
} from '../../src/types/subscription';
7+
import { useSubscriptionStore } from '../../src/store/subscriptionStore';
8+
import {
9+
elasticsearchService,
10+
SearchQuery,
11+
SearchResult,
12+
SavedSearchDefinition,
13+
SavedSearchMatchNotification,
14+
} from '../../backend/services/search/ElasticsearchService';
1515

16-
export type SavedSearch = {
17-
id: string;
18-
name: string;
19-
query: string;
20-
filters?: any;
21-
};
16+
export type { SearchQuery, SearchResult, SavedSearchDefinition, SavedSearchMatchNotification };
17+
18+
export type SearchFilters = NonNullable<SearchQuery['filters']>;
19+
20+
export type SavedSearch = SavedSearchDefinition;
21+
22+
const SAVED_SEARCHES_KEY = 'subtrackr-saved-searches';
2223

23-
export type SearchResult = {
24-
subscriptions: Subscription[];
25-
total: number;
26-
// potential analytics hook-in placeholders
27-
// analytics?: any;
24+
const toBackendQuery = (query: SearchQuery): SearchQuery => query;
25+
26+
const syncIndex = (): void => {
27+
const subscriptions = useSubscriptionStore.getState().subscriptions ?? [];
28+
elasticsearchService.bulkIndex(subscriptions);
2829
};
2930

30-
// Very lightweight full-text + facet search on subscriptions
3131
export const search_subscriptions = (query: SearchQuery): SearchResult => {
32-
const all = useSubscriptionStore.getState().subscriptions || [];
33-
const { query: q, filters } = query;
34-
35-
const text = (s: Subscription) => {
36-
const fields = [s.name, s.description, String(s.category), s.currency, String(s.price)];
37-
return fields.filter(Boolean).join(' ').toLowerCase();
38-
};
39-
40-
const normalizedQ = (q || '').toLowerCase().trim();
41-
42-
const filtered = all.filter((sub) => {
43-
// quick skip if not active and user asked for activeOnly
44-
if (filters?.activeOnly && sub.isActive !== true) return false;
45-
// category facet
46-
if (filters?.category && sub.category !== filters!.category) return false;
47-
// price filters
48-
if (typeof filters?.minPrice === 'number' && sub.price < filters!.minPrice) return false;
49-
if (typeof filters?.maxPrice === 'number' && sub.price > filters!.maxPrice) return false;
50-
// full-text search against multiple fields
51-
if (normalizedQ) {
52-
const hay = text(sub);
53-
return hay.includes(normalizedQ);
54-
}
55-
return true;
56-
});
57-
58-
// Sorting (simple): by nextBillingDate asc if available, else by createdAt
59-
const sorted = filtered.slice().sort((a, b) => {
60-
const ta = a.nextBillingDate?.getTime?.() ?? 0;
61-
const tb = b.nextBillingDate?.getTime?.() ?? 0;
62-
return ta - tb;
63-
});
64-
65-
// export-friendly result: total and items
66-
return { subscriptions: sorted, total: sorted.length };
32+
syncIndex();
33+
return elasticsearchService.search(toBackendQuery(query));
6734
};
6835

69-
export const save_search = async (search: SavedSearch): Promise<void> => {
70-
// Simple persistence via local store if needed; here we'll just attempt to attach to settings if available
71-
// In a full implementation, we'd persist to AsyncStorage or backend; keep minimal for now
72-
const _ = search; // placeholder to signal intent
73-
return Promise.resolve();
36+
export const index_subscription = (subscription: Subscription): void => {
37+
elasticsearchService.indexDocument(subscription);
38+
};
39+
40+
export const remove_subscription_from_index = (id: string): void => {
41+
elasticsearchService.deleteDocument(id);
7442
};
7543

7644
export const get_search_suggestions = (partial: string): string[] => {
45+
syncIndex();
7746
const suggestions = new Set<string>();
78-
const subs = useSubscriptionStore.getState().subscriptions || [];
79-
const q = partial.toLowerCase();
47+
const q = partial.toLowerCase().trim();
48+
if (!q) return [];
49+
50+
const subs = useSubscriptionStore.getState().subscriptions ?? [];
8051
for (const sub of subs) {
81-
if (sub.name.toLowerCase().includes(q)) suggestions.add(sub.name);
82-
if (sub.description && sub.description.toLowerCase().includes(q)) suggestions.add(sub.description);
52+
const candidates = [
53+
sub.name,
54+
sub.planName,
55+
sub.customerName,
56+
sub.customerEmail,
57+
sub.notes,
58+
sub.description,
59+
].filter(Boolean) as string[];
60+
61+
for (const value of candidates) {
62+
if (value.toLowerCase().includes(q)) suggestions.add(value);
63+
}
64+
}
65+
66+
for (const category of Object.values(SubscriptionCategory)) {
67+
if (category.toLowerCase().includes(q)) suggestions.add(category);
68+
}
69+
70+
for (const top of elasticsearchService.getTopQueries(5)) {
71+
if (top.query.includes(q)) suggestions.add(top.query);
8372
}
84-
// also suggest categories
85-
const categories = Object.values(SubscriptionCategory);
86-
for (const c of categories) if ((c as string).toLowerCase().includes(q)) suggestions.add(c);
87-
return Array.from(suggestions).slice(0, 5);
73+
74+
return Array.from(suggestions).slice(0, 8);
75+
};
76+
77+
export const load_saved_searches = async (): Promise<SavedSearch[]> => {
78+
const raw = await AsyncStorage.getItem(SAVED_SEARCHES_KEY);
79+
if (!raw) return [];
80+
const parsed = JSON.parse(raw) as SavedSearch[];
81+
elasticsearchService.loadSavedSearches(parsed);
82+
return parsed;
83+
};
84+
85+
export const save_search = async (search: SavedSearch): Promise<void> => {
86+
const existing = await load_saved_searches();
87+
const next = existing.some((s) => s.id === search.id)
88+
? existing.map((s) => (s.id === search.id ? search : s))
89+
: [...existing, search];
90+
91+
elasticsearchService.loadSavedSearches(next);
92+
await AsyncStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
93+
elasticsearchService.registerSavedSearch(search);
94+
};
95+
96+
export const delete_saved_search = async (id: string): Promise<void> => {
97+
const existing = await load_saved_searches();
98+
const next = existing.filter((s) => s.id !== id);
99+
elasticsearchService.loadSavedSearches(next);
100+
elasticsearchService.removeSavedSearch(id);
101+
await AsyncStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
88102
};
103+
104+
export const check_saved_search_notifications = (): SavedSearchMatchNotification[] => {
105+
syncIndex();
106+
return elasticsearchService.checkSavedSearchNotifications();
107+
};
108+
109+
export const buildDefaultFilters = (): SearchFilters => ({
110+
categories: [],
111+
billingCycles: [],
112+
plans: [],
113+
statuses: [],
114+
priceRange: { min: 0, max: Number.MAX_SAFE_INTEGER },
115+
});
116+
117+
export const formatHighlight = (highlight?: string, fallback = ''): string => {
118+
if (!highlight) return fallback;
119+
return highlight.replace(/<\/?em>/g, '');
120+
};
121+
122+
export const hasHighlightMatch = (highlight?: string): boolean =>
123+
Boolean(highlight && highlight.includes('<em>'));
124+
125+
export const getBillingCycleLabel = (cycle: BillingCycle): string =>
126+
cycle.charAt(0).toUpperCase() + cycle.slice(1);
127+
128+
export const getCategoryLabel = (category: SubscriptionCategory): string =>
129+
category.charAt(0).toUpperCase() + category.slice(1);

0 commit comments

Comments
 (0)