Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
490 changes: 436 additions & 54 deletions app/screens/AdvancedSearchScreen.tsx

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions app/services/__tests__/searchService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
ElasticsearchService,
elasticsearchService,
} from '../../../backend/services/search/ElasticsearchService';
import {
Subscription,
SubscriptionCategory,
BillingCycle,
} from '../../../src/types/subscription';

jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock')
);

jest.mock('../../../src/store/subscriptionStore', () => ({
useSubscriptionStore: {
getState: () => ({
subscriptions: [
{
id: '1',
name: 'Netflix',
planName: 'Netflix Premium',
customerName: 'Jane Doe',
customerEmail: 'jane@example.com',
notes: 'auto-renew',
category: SubscriptionCategory.STREAMING,
price: 15.99,
currency: 'USD',
billingCycle: BillingCycle.MONTHLY,
nextBillingDate: new Date('2026-05-01'),
isActive: true,
isCryptoEnabled: false,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
},
],
}),
},
}));

describe('searchService', () => {
beforeEach(() => {
elasticsearchService.bulkIndex([]);
});

it('delegates full-text search to ElasticsearchService', async () => {
const { search_subscriptions } = require('../searchService');
const result = search_subscriptions({ query: 'Jane' });
expect(result.total).toBe(1);
expect(result.hits[0].subscription.customerName).toBe('Jane Doe');
});

it('returns suggestions for partial queries', () => {
const { get_search_suggestions } = require('../searchService');
const suggestions = get_search_suggestions('net');
expect(suggestions.length).toBeGreaterThan(0);
});

it('persists saved searches', async () => {
const { save_search, load_saved_searches } = require('../searchService');
await save_search({
id: 'saved-1',
name: 'VIP',
query: { query: 'Jane' },
notifyOnNewMatches: true,
createdAt: Date.now(),
});
const saved = await load_saved_searches();
expect(saved.some((item: { id: string }) => item.id === 'saved-1')).toBe(true);
});
});

describe('ElasticsearchService saved search notifications', () => {
it('notifies when match count increases', () => {
const service = new ElasticsearchService();
const sub: Subscription = {
id: '1',
name: 'Acme',
category: SubscriptionCategory.SOFTWARE,
price: 10,
currency: 'USD',
billingCycle: BillingCycle.MONTHLY,
nextBillingDate: new Date(),
isActive: true,
isCryptoEnabled: false,
createdAt: new Date(),
updatedAt: new Date(),
};

service.bulkIndex([]);
service.registerSavedSearch({
id: 'saved-2',
name: 'Acme matches',
query: { query: 'Acme' },
notifyOnNewMatches: true,
lastMatchCount: 0,
createdAt: Date.now(),
});

expect(service.checkSavedSearchNotifications()).toEqual([]);
service.indexDocument(sub);
const notifications = service.checkSavedSearchNotifications();
expect(notifications[0].newMatchCount).toBe(1);
});
});
189 changes: 115 additions & 74 deletions app/services/searchService.ts
Original file line number Diff line number Diff line change
@@ -1,88 +1,129 @@
import { Subscription, SubscriptionCategory } from '../types/subscription';
import { useSubscriptionStore } from '../store/subscriptionStore';
import { currencyService } from './currencyService';
import { useSettingsStore } from '../store/settingsStore';

export type SearchQuery = {
query: string;
filters?: {
category?: SubscriptionCategory | string;
minPrice?: number;
maxPrice?: number;
activeOnly?: boolean;
};
};
import AsyncStorage from '@react-native-async-storage/async-storage';
import {
Subscription,
SubscriptionCategory,
BillingCycle,
} from '../../src/types/subscription';
import { useSubscriptionStore } from '../../src/store/subscriptionStore';
import {
elasticsearchService,
SearchQuery,
SearchResult,
SavedSearchDefinition,
SavedSearchMatchNotification,
} from '../../backend/services/search/ElasticsearchService';

export type SavedSearch = {
id: string;
name: string;
query: string;
filters?: any;
};
export type { SearchQuery, SearchResult, SavedSearchDefinition, SavedSearchMatchNotification };

export type SearchFilters = NonNullable<SearchQuery['filters']>;

export type SavedSearch = SavedSearchDefinition;

const SAVED_SEARCHES_KEY = 'subtrackr-saved-searches';

export type SearchResult = {
subscriptions: Subscription[];
total: number;
// potential analytics hook-in placeholders
// analytics?: any;
const toBackendQuery = (query: SearchQuery): SearchQuery => query;

const syncIndex = (): void => {
const subscriptions = useSubscriptionStore.getState().subscriptions ?? [];
elasticsearchService.bulkIndex(subscriptions);
};

// Very lightweight full-text + facet search on subscriptions
export const search_subscriptions = (query: SearchQuery): SearchResult => {
const all = useSubscriptionStore.getState().subscriptions || [];
const { query: q, filters } = query;

const text = (s: Subscription) => {
const fields = [s.name, s.description, String(s.category), s.currency, String(s.price)];
return fields.filter(Boolean).join(' ').toLowerCase();
};

const normalizedQ = (q || '').toLowerCase().trim();

const filtered = all.filter((sub) => {
// quick skip if not active and user asked for activeOnly
if (filters?.activeOnly && sub.isActive !== true) return false;
// category facet
if (filters?.category && sub.category !== filters!.category) return false;
// price filters
if (typeof filters?.minPrice === 'number' && sub.price < filters!.minPrice) return false;
if (typeof filters?.maxPrice === 'number' && sub.price > filters!.maxPrice) return false;
// full-text search against multiple fields
if (normalizedQ) {
const hay = text(sub);
return hay.includes(normalizedQ);
}
return true;
});

// Sorting (simple): by nextBillingDate asc if available, else by createdAt
const sorted = filtered.slice().sort((a, b) => {
const ta = a.nextBillingDate?.getTime?.() ?? 0;
const tb = b.nextBillingDate?.getTime?.() ?? 0;
return ta - tb;
});

// export-friendly result: total and items
return { subscriptions: sorted, total: sorted.length };
syncIndex();
return elasticsearchService.search(toBackendQuery(query));
};

export const save_search = async (search: SavedSearch): Promise<void> => {
// Simple persistence via local store if needed; here we'll just attempt to attach to settings if available
// In a full implementation, we'd persist to AsyncStorage or backend; keep minimal for now
const _ = search; // placeholder to signal intent
return Promise.resolve();
export const index_subscription = (subscription: Subscription): void => {
elasticsearchService.indexDocument(subscription);
};

export const remove_subscription_from_index = (id: string): void => {
elasticsearchService.deleteDocument(id);
};

export const get_search_suggestions = (partial: string): string[] => {
syncIndex();
const suggestions = new Set<string>();
const subs = useSubscriptionStore.getState().subscriptions || [];
const q = partial.toLowerCase();
const q = partial.toLowerCase().trim();
if (!q) return [];

const subs = useSubscriptionStore.getState().subscriptions ?? [];
for (const sub of subs) {
if (sub.name.toLowerCase().includes(q)) suggestions.add(sub.name);
if (sub.description && sub.description.toLowerCase().includes(q)) suggestions.add(sub.description);
const candidates = [
sub.name,
sub.planName,
sub.customerName,
sub.customerEmail,
sub.notes,
sub.description,
].filter(Boolean) as string[];

for (const value of candidates) {
if (value.toLowerCase().includes(q)) suggestions.add(value);
}
}

for (const category of Object.values(SubscriptionCategory)) {
if (category.toLowerCase().includes(q)) suggestions.add(category);
}

for (const top of elasticsearchService.getTopQueries(5)) {
if (top.query.includes(q)) suggestions.add(top.query);
}
// also suggest categories
const categories = Object.values(SubscriptionCategory);
for (const c of categories) if ((c as string).toLowerCase().includes(q)) suggestions.add(c);
return Array.from(suggestions).slice(0, 5);

return Array.from(suggestions).slice(0, 8);
};

export const load_saved_searches = async (): Promise<SavedSearch[]> => {
const raw = await AsyncStorage.getItem(SAVED_SEARCHES_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as SavedSearch[];
elasticsearchService.loadSavedSearches(parsed);
return parsed;
};

export const save_search = async (search: SavedSearch): Promise<void> => {
const existing = await load_saved_searches();
const next = existing.some((s) => s.id === search.id)
? existing.map((s) => (s.id === search.id ? search : s))
: [...existing, search];

elasticsearchService.loadSavedSearches(next);
await AsyncStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
elasticsearchService.registerSavedSearch(search);
};

export const delete_saved_search = async (id: string): Promise<void> => {
const existing = await load_saved_searches();
const next = existing.filter((s) => s.id !== id);
elasticsearchService.loadSavedSearches(next);
elasticsearchService.removeSavedSearch(id);
await AsyncStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
};

export const check_saved_search_notifications = (): SavedSearchMatchNotification[] => {
syncIndex();
return elasticsearchService.checkSavedSearchNotifications();
};

export const buildDefaultFilters = (): SearchFilters => ({
categories: [],
billingCycles: [],
plans: [],
statuses: [],
priceRange: { min: 0, max: Number.MAX_SAFE_INTEGER },
});

export const formatHighlight = (highlight?: string, fallback = ''): string => {
if (!highlight) return fallback;
return highlight.replace(/<\/?em>/g, '');
};

export const hasHighlightMatch = (highlight?: string): boolean =>
Boolean(highlight && highlight.includes('<em>'));

export const getBillingCycleLabel = (cycle: BillingCycle): string =>
cycle.charAt(0).toUpperCase() + cycle.slice(1);

export const getCategoryLabel = (category: SubscriptionCategory): string =>
category.charAt(0).toUpperCase() + category.slice(1);
Loading