diff --git a/app/_layout.tsx b/app/_layout.tsx
index 11e1b1f..0a3e69d 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,15 +1,21 @@
-import { Stack, useRouter, usePathname, useSegments } from "expo-router";
-import React, { useCallback, useEffect } from "react";
-import { GestureHandlerRootView } from "react-native-gesture-handler";
-import "react-native-reanimated";
-import "../global.css"; // NativeWind CSS
-import { AnalyticsProvider, ErrorBoundary, OfflineIndicatorProvider } from "../src/components";
+import { Stack, useRouter, usePathname, useSegments } from 'expo-router';
+import React, { useCallback, useEffect } from 'react';
+import { GestureHandlerRootView } from 'react-native-gesture-handler';
+
+import 'react-native-reanimated';
+import '../global.css'; // NativeWind CSS
+import {
+ AnalyticsProvider,
+ ErrorBoundary,
+ OfflineIndicatorProvider,
+ SearchIndexProvider,
+} from '../src/components';
import { useAnalytics } from '../src/hooks';
import { useDeepLink } from '../src/hooks/useDeepLink';
import { getPathFromDeepLink } from '../src/utils/linkParser';
// Component to handle auto screen tracking
-function ScreenTracker() {
+const ScreenTracker = () => {
const pathname = usePathname();
const segments = useSegments();
const { trackScreen } = useAnalytics();
@@ -22,17 +28,20 @@ function ScreenTracker() {
}, [pathname, segments, trackScreen]);
return null;
-}
+};
-export default function RootLayout() {
+const RootLayout = () => {
const router = useRouter();
- const handleDeepLink = useCallback((deepLink) => {
- const path = getPathFromDeepLink(deepLink);
- if (path) {
- router.replace(path);
- }
- }, [router]);
+ const handleDeepLink = useCallback(
+ deepLink => {
+ const path = getPathFromDeepLink(deepLink);
+ if (path) {
+ router.replace(path);
+ }
+ },
+ [router]
+ );
useDeepLink(handleDeepLink);
@@ -41,20 +50,24 @@ export default function RootLayout() {
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
-}
+};
+
+export default RootLayout;
diff --git a/src/__tests__/services/searchIndex/SearchIndex.test.ts b/src/__tests__/services/searchIndex/SearchIndex.test.ts
new file mode 100644
index 0000000..9f1eead
--- /dev/null
+++ b/src/__tests__/services/searchIndex/SearchIndex.test.ts
@@ -0,0 +1,195 @@
+import { SearchIndex } from '../../../services/searchIndex/SearchIndex';
+import { IndexableDoc } from '../../../services/searchIndex/types';
+
+function doc(
+ id: string,
+ title: string,
+ body = '',
+ category = '',
+ level: 'beginner' | 'intermediate' | 'advanced' = 'beginner'
+): IndexableDoc {
+ return { id, type: 'course', fields: { title, body, category, level } };
+}
+
+describe('SearchIndex', () => {
+ let index: SearchIndex;
+
+ beforeEach(() => {
+ index = new SearchIndex();
+ });
+
+ describe('build / size / clear', () => {
+ it('reports size after build', () => {
+ index.build([doc('1', 'A'), doc('2', 'B')]);
+ expect(index.size()).toBe(2);
+ expect(index.isReady()).toBe(true);
+ });
+
+ it('clear empties the index', () => {
+ index.build([doc('1', 'A')]);
+ index.clear();
+ expect(index.size()).toBe(0);
+ expect(index.isReady()).toBe(false);
+ });
+ });
+
+ describe('search', () => {
+ beforeEach(() => {
+ index.build([
+ doc('1', 'React Native Fundamentals', 'Learn mobile development', 'Mobile Development'),
+ doc('2', 'Advanced JavaScript', 'Closures and prototypes', 'Web Development', 'advanced'),
+ doc('3', 'Mobile UI Design', 'Design beautiful interfaces', 'Design'),
+ doc('4', 'React Hooks Deep Dive', 'useState useEffect useMemo', 'Web Development'),
+ doc('5', 'Native Modules in Expo', 'Bridging native code', 'Mobile Development'),
+ ]);
+ });
+
+ it('returns empty when no token matches', () => {
+ expect(index.search('nonsensequery')).toEqual([]);
+ });
+
+ it('finds title matches', () => {
+ const hits = index.search('react');
+ const ids = hits.map(h => h.id);
+ expect(ids).toEqual(expect.arrayContaining(['1', '4']));
+ });
+
+ it('intersects multi-token queries (AND)', () => {
+ const hits = index.search('react native');
+ expect(hits.map(h => h.id)).toEqual(['1']);
+ });
+
+ it('matches across body and category', () => {
+ const hits = index.search('design');
+ expect(hits.map(h => h.id)).toEqual(expect.arrayContaining(['3']));
+ });
+
+ it('supports prefix match on the trailing token', () => {
+ const hits = index.search('rea');
+ const ids = hits.map(h => h.id);
+ expect(ids).toEqual(expect.arrayContaining(['1', '4']));
+ });
+
+ it('ranks title hits above body-only hits', () => {
+ const fresh = new SearchIndex();
+ fresh.build([
+ doc('a', 'Body Only', 'this lesson covers expo deeply', 'Web Development'),
+ doc('b', 'Expo Deep Dive', 'introductory material', 'Mobile Development'),
+ ]);
+ const hits = fresh.search('expo');
+ expect(hits[0].id).toBe('b');
+ });
+
+ it('applies category filters', () => {
+ const hits = index.search('mobile', { filters: { category: 'Mobile Development' } });
+ expect(hits.every(h => h.doc.fields.category === 'Mobile Development')).toBe(true);
+ });
+
+ it('applies level filters', () => {
+ const hits = index.search('javascript', { filters: { level: 'advanced' } });
+ expect(hits.map(h => h.id)).toEqual(['2']);
+ });
+
+ it('respects the limit option', () => {
+ const hits = index.search('react', { limit: 1 });
+ expect(hits).toHaveLength(1);
+ });
+
+ it('returns filtered docs when query is empty', () => {
+ const hits = index.search('', { filters: { category: 'Design' }, limit: 10 });
+ expect(hits.map(h => h.id)).toEqual(['3']);
+ });
+ });
+
+ describe('update / remove', () => {
+ it('update inserts a new doc', () => {
+ index.build([doc('1', 'A')]);
+ index.update(doc('2', 'Beta'));
+ expect(index.size()).toBe(2);
+ expect(index.search('beta').map(h => h.id)).toEqual(['2']);
+ });
+
+ it('update replaces an existing doc', () => {
+ index.build([doc('1', 'Old Title')]);
+ index.update(doc('1', 'New Title'));
+ expect(index.search('old')).toEqual([]);
+ expect(index.search('new').map(h => h.id)).toEqual(['1']);
+ });
+
+ it('remove drops a doc and its postings', () => {
+ index.build([doc('1', 'Alpha'), doc('2', 'Beta')]);
+ index.remove('1');
+ expect(index.size()).toBe(1);
+ expect(index.search('alpha')).toEqual([]);
+ });
+ });
+
+ describe('serialize / hydrate', () => {
+ it('round-trips through a snapshot', () => {
+ index.build([doc('1', 'Alpha'), doc('2', 'Beta')]);
+ const snap = index.serialize();
+ const fresh = new SearchIndex();
+ fresh.hydrate(snap);
+ expect(fresh.size()).toBe(2);
+ expect(fresh.search('alpha').map(h => h.id)).toEqual(['1']);
+ });
+
+ it('rejects snapshots with mismatched version', () => {
+ index.build([doc('1', 'Alpha')]);
+ const snap = index.serialize();
+ const fresh = new SearchIndex();
+ fresh.hydrate({ ...snap, version: 999 });
+ expect(fresh.size()).toBe(0);
+ });
+ });
+
+ describe('performance', () => {
+ it('searches 500 docs in under 100ms', () => {
+ const docs: IndexableDoc[] = [];
+ const categories = ['Mobile Development', 'Web Development', 'Design', 'Data'];
+ const wordPool = [
+ 'react',
+ 'native',
+ 'expo',
+ 'javascript',
+ 'typescript',
+ 'design',
+ 'mobile',
+ 'web',
+ 'animation',
+ 'navigation',
+ 'hooks',
+ 'modules',
+ 'performance',
+ 'testing',
+ 'analytics',
+ 'data',
+ 'rendering',
+ ];
+ for (let i = 0; i < 500; i++) {
+ const w1 = wordPool[i % wordPool.length];
+ const w2 = wordPool[(i * 3) % wordPool.length];
+ const w3 = wordPool[(i * 7) % wordPool.length];
+ docs.push({
+ id: `c${i}`,
+ type: 'course',
+ fields: {
+ title: `${w1} ${w2} course ${i}`,
+ body: `An in-depth look at ${w1} and ${w3}, covering practical examples.`,
+ category: categories[i % categories.length],
+ level: 'beginner',
+ },
+ });
+ }
+ index.build(docs);
+
+ const start = Date.now();
+ for (let i = 0; i < 10; i++) {
+ index.search('react native', { limit: 25 });
+ }
+ const elapsed = Date.now() - start;
+ const perCall = elapsed / 10;
+ expect(perCall).toBeLessThan(100);
+ });
+ });
+});
diff --git a/src/__tests__/services/searchIndex/persistence.test.ts b/src/__tests__/services/searchIndex/persistence.test.ts
new file mode 100644
index 0000000..bd66716
--- /dev/null
+++ b/src/__tests__/services/searchIndex/persistence.test.ts
@@ -0,0 +1,98 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+import {
+ SNAPSHOT_KEY,
+ clearSnapshot,
+ loadSnapshot,
+ saveSnapshot,
+} from '../../../services/searchIndex/persistence';
+import { INDEX_VERSION, IndexSnapshot } from '../../../services/searchIndex/types';
+
+const setItem = AsyncStorage.setItem as jest.Mock;
+const getItem = AsyncStorage.getItem as jest.Mock;
+const removeItem = AsyncStorage.removeItem as jest.Mock;
+
+function withInMemoryStore() {
+ const store = new Map();
+ setItem.mockImplementation((k: string, v: string) => {
+ store.set(k, v);
+ return Promise.resolve();
+ });
+ getItem.mockImplementation((k: string) => Promise.resolve(store.get(k) ?? null));
+ removeItem.mockImplementation((k: string) => {
+ store.delete(k);
+ return Promise.resolve();
+ });
+ return store;
+}
+
+function snapshot(docs: IndexSnapshot['docs'] = []): IndexSnapshot {
+ return {
+ version: INDEX_VERSION,
+ hash: 'h1',
+ builtAt: Date.now(),
+ docs,
+ };
+}
+
+describe('searchIndex/persistence', () => {
+ beforeEach(() => {
+ setItem.mockReset();
+ getItem.mockReset();
+ removeItem.mockReset();
+ });
+
+ it('round-trips a snapshot through AsyncStorage', async () => {
+ withInMemoryStore();
+ const snap = snapshot([
+ { id: '1', type: 'course', fields: { title: 'Alpha' } },
+ { id: '2', type: 'course', fields: { title: 'Beta' } },
+ ]);
+ const ok = await saveSnapshot(snap);
+ expect(ok).toBe(true);
+
+ const loaded = await loadSnapshot();
+ expect(loaded).not.toBeNull();
+ expect(loaded?.docs).toHaveLength(2);
+ expect(loaded?.docs[0].fields.title).toBe('Alpha');
+ });
+
+ it('returns null when no snapshot is stored', async () => {
+ getItem.mockResolvedValueOnce(null);
+ const loaded = await loadSnapshot();
+ expect(loaded).toBeNull();
+ });
+
+ it('returns null when stored payload is corrupt', async () => {
+ getItem.mockResolvedValueOnce('{not json');
+ const loaded = await loadSnapshot();
+ expect(loaded).toBeNull();
+ });
+
+ it('returns null when version is mismatched', async () => {
+ getItem.mockResolvedValueOnce(
+ JSON.stringify({ version: 999, hash: 'x', builtAt: 0, docs: [] })
+ );
+ const loaded = await loadSnapshot();
+ expect(loaded).toBeNull();
+ });
+
+ it('skips persistence when payload exceeds size cap', async () => {
+ withInMemoryStore();
+ const huge = 'x'.repeat(50_000);
+ const docs: IndexSnapshot['docs'] = [];
+ for (let i = 0; i < 10; i++) {
+ docs.push({ id: `d${i}`, type: 'course', fields: { title: huge, body: huge } });
+ }
+ const ok = await saveSnapshot(snapshot(docs));
+ expect(ok).toBe(false);
+ expect(setItem).not.toHaveBeenCalled();
+ });
+
+ it('clearSnapshot removes the stored entry', async () => {
+ const store = withInMemoryStore();
+ store.set(SNAPSHOT_KEY, '{"version":1}');
+ await clearSnapshot();
+ expect(removeItem).toHaveBeenCalledWith(SNAPSHOT_KEY);
+ });
+});
diff --git a/src/__tests__/services/searchIndex/tokenize.test.ts b/src/__tests__/services/searchIndex/tokenize.test.ts
new file mode 100644
index 0000000..81721cc
--- /dev/null
+++ b/src/__tests__/services/searchIndex/tokenize.test.ts
@@ -0,0 +1,60 @@
+import {
+ tokenize,
+ normalize,
+ uniqueTokens,
+ STOPWORDS,
+} from '../../../services/searchIndex/tokenize';
+
+describe('searchIndex/tokenize', () => {
+ describe('normalize', () => {
+ it('lowercases input', () => {
+ expect(normalize('React Native')).toBe('react native');
+ });
+
+ it('strips diacritics', () => {
+ expect(normalize('Café')).toBe('cafe');
+ expect(normalize('Año')).toBe('ano');
+ });
+ });
+
+ describe('tokenize', () => {
+ it('returns an empty array for empty input', () => {
+ expect(tokenize('')).toEqual([]);
+ });
+
+ it('splits on whitespace and punctuation', () => {
+ expect(tokenize('React-Native: A primer.')).toEqual(['react', 'native', 'primer']);
+ });
+
+ it('drops single-character tokens', () => {
+ expect(tokenize('a b cc dd')).toEqual(['cc', 'dd']);
+ });
+
+ it('drops stopwords', () => {
+ const tokens = tokenize('Learn the basics of mobile development');
+ expect(tokens).not.toContain('the');
+ expect(tokens).not.toContain('of');
+ expect(tokens).toEqual(expect.arrayContaining(['learn', 'basics', 'mobile', 'development']));
+ });
+
+ it('keeps numerics', () => {
+ expect(tokenize('iOS 17 release notes')).toEqual(
+ expect.arrayContaining(['ios', '17', 'release', 'notes'])
+ );
+ });
+ });
+
+ describe('uniqueTokens', () => {
+ it('deduplicates tokens', () => {
+ expect(uniqueTokens('react react native react')).toEqual(['react', 'native']);
+ });
+ });
+
+ describe('STOPWORDS', () => {
+ it('contains common English stopwords', () => {
+ expect(STOPWORDS.has('the')).toBe(true);
+ expect(STOPWORDS.has('and')).toBe(true);
+ expect(STOPWORDS.has('with')).toBe(true);
+ });
+ });
+});
diff --git a/src/components/SearchIndexProvider.tsx b/src/components/SearchIndexProvider.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/index.ts b/src/components/index.ts
index 2bd2dec..ed81a01 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -12,3 +12,6 @@ export { CourseCardSkeleton } from './mobile/CourseCardSkeleton';
export { default as MobileCourseViewer } from './mobile/MobileCourseViewer';
export { MobileHeader } from './mobile/MobileHeader';
export { default as MobileQuizManager } from './mobile/MobileQuizManager';
+
+export { SearchIndexProvider } from './SearchIndexProvider';
+export type { SearchIndexContextValue } from './SearchIndexProvider';
diff --git a/src/components/mobile/MobileSearch.tsx b/src/components/mobile/MobileSearch.tsx
index e2acbca..1c5cc22 100644
--- a/src/components/mobile/MobileSearch.tsx
+++ b/src/components/mobile/MobileSearch.tsx
@@ -9,17 +9,23 @@ import {
TouchableOpacity,
View,
} from 'react-native';
-import { AppText as Text } from '../common/AppText';
-import { useAnalytics, useDebounce, useDynamicFontSize, useMemoryMonitor } from '../../hooks';
-import { AnalyticsEvent } from '../../utils/trackingEvents';
+
import { FilterField, FilterSheet, FilterValues } from './FilterSheet';
import { SearchHistory } from './SearchHistory';
import { SearchResultCard, SearchResultItem } from './SearchResultCard';
+import { VoiceSearch } from './VoiceSearch';
+import {
+ useAnalytics,
+ useDebounce,
+ useDynamicFontSize,
+ useMemoryMonitor,
+ useSearchIndex,
+} from '../../hooks';
+import { SearchHit } from '../../services/searchIndex';
import { addToSearchHistory } from '../../utils/searchHistory';
+import { AnalyticsEvent } from '../../utils/trackingEvents';
import { validateSearchQuery } from '../../utils/validation';
-import { sampleCourse } from '../../data/sampleCourse';
-import { Course } from '../../types/course';
-import { VoiceSearch } from './VoiceSearch';
+import { AppText as Text } from '../common/AppText';
const DEFAULT_FILTERS: FilterField[] = [
{
@@ -52,31 +58,20 @@ const SUGGESTION_KEYWORDS = [
'beginner',
];
-function courseToSearchResult(course: Course): SearchResultItem {
+function hitToSearchResult(hit: SearchHit): SearchResultItem {
+ const payload = (hit.doc.payload ?? {}) as {
+ totalDuration?: number;
+ };
return {
- id: course.id,
- title: course.title,
- description: course.description,
- category: course.category,
- level: course.level,
- duration: course.totalDuration,
+ id: hit.id,
+ title: hit.doc.fields.title,
+ description: hit.doc.fields.body ?? '',
+ category: hit.doc.fields.category ?? '',
+ level: (hit.doc.fields.level as SearchResultItem['level']) ?? 'beginner',
+ duration: payload.totalDuration ?? 0,
};
}
-function filterCourse(course: Course, query: string, filters: FilterValues): boolean {
- const q = query.trim().toLowerCase();
- if (q) {
- const match =
- course.title.toLowerCase().includes(q) ||
- course.description.toLowerCase().includes(q) ||
- course.category.toLowerCase().includes(q);
- if (!match) return false;
- }
- if (filters.category && course.category !== filters.category) return false;
- if (filters.level && course.level !== filters.level) return false;
- return true;
-}
-
/**
* Props for the MobileSearch component
*/
@@ -100,6 +95,7 @@ export const MobileSearch = ({
const [hasSearched, setHasSearched] = useState(false);
const { scale } = useDynamicFontSize();
const { trackEvent } = useAnalytics();
+ const { search: indexSearch } = useSearchIndex();
useMemoryMonitor({ componentId: 'MobileSearch', itemCount: results.length });
@@ -126,14 +122,19 @@ export const MobileSearch = ({
const trimmed = searchQuery.trim();
addToSearchHistory(trimmed);
trackEvent(AnalyticsEvent.SEARCH_QUERY, { query: trimmed, filters: filterValues });
- const filtered = filterCourse(sampleCourse, trimmed, filterValues)
- ? [courseToSearchResult(sampleCourse)]
- : [];
- setResults(filtered);
+ const hits = indexSearch(trimmed, {
+ filters: {
+ category: filterValues.category,
+ level: filterValues.level,
+ type: 'course',
+ },
+ limit: 50,
+ });
+ setResults(hits.map(hitToSearchResult));
setHasSearched(true);
setSuggestionsVisible(false);
},
- [filterValues, trackEvent]
+ [filterValues, trackEvent, indexSearch]
);
React.useEffect(() => {
@@ -146,7 +147,6 @@ export const MobileSearch = ({
}
}, [debouncedQuery, performSearch]);
-
const handleSubmit = useCallback(() => {
performSearch(query);
}, [query, performSearch]);
@@ -198,7 +198,10 @@ export const MobileSearch = ({
placeholder={placeholder}
placeholderTextColor="#9CA3AF"
value={query}
- onChangeText={(text) => { setQuery(text); setQueryError(null); }}
+ onChangeText={text => {
+ setQuery(text);
+ setQueryError(null);
+ }}
onFocus={() => setSuggestionsVisible(true)}
onBlur={() => setTimeout(() => setSuggestionsVisible(false), 180)}
onSubmitEditing={handleSubmit}
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index ef8dc5f..1e79b26 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -22,3 +22,4 @@ export * from './useSwipe';
export * from './useVideoGestures';
export * from './useVoiceRecognition';
export * from './useDebounce';
+export * from './useSearchIndex';
diff --git a/src/hooks/useSearchIndex.ts b/src/hooks/useSearchIndex.ts
new file mode 100644
index 0000000..e08c261
--- /dev/null
+++ b/src/hooks/useSearchIndex.ts
@@ -0,0 +1,16 @@
+import { useContext } from 'react';
+
+import { SearchIndexContext, SearchIndexContextValue } from '../components/SearchIndexProvider';
+
+export function useSearchIndex(): SearchIndexContextValue {
+ const ctx = useContext(SearchIndexContext);
+ if (!ctx) {
+ return {
+ ready: false,
+ size: 0,
+ search: () => [],
+ rebuild: async () => {},
+ };
+ }
+ return ctx;
+}
diff --git a/src/services/searchIndex/SearchIndex.ts b/src/services/searchIndex/SearchIndex.ts
new file mode 100644
index 0000000..d954fe3
--- /dev/null
+++ b/src/services/searchIndex/SearchIndex.ts
@@ -0,0 +1,241 @@
+import { tokenize, uniqueTokens } from './tokenize';
+import {
+ FIELD_WEIGHTS,
+ INDEX_VERSION,
+ IndexSnapshot,
+ IndexableDoc,
+ SearchHit,
+ SearchOptions,
+} from './types';
+
+const DEFAULT_LIMIT = 25;
+const PREFIX_BONUS = 0.5;
+
+function fieldText(doc: IndexableDoc): [keyof typeof FIELD_WEIGHTS, string][] {
+ const f = doc.fields;
+ const out: [keyof typeof FIELD_WEIGHTS, string][] = [];
+ if (f.title) out.push(['title', f.title]);
+ if (f.category) out.push(['category', f.category]);
+ if (f.level) out.push(['level', f.level]);
+ if (f.body) out.push(['body', f.body]);
+ if (f.extra) out.push(['extra', f.extra]);
+ return out;
+}
+
+function hashContent(docs: IndexableDoc[]): string {
+ let h = 5381;
+ for (const d of docs) {
+ const s = `${d.id}|${d.fields.title}|${d.fields.category ?? ''}|${d.fields.level ?? ''}|${d.fields.body ?? ''}|${d.fields.extra ?? ''}`;
+ for (let i = 0; i < s.length; i++) {
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
+ }
+ }
+ return (h >>> 0).toString(36);
+}
+
+/**
+ * In-memory inverted index for offline search.
+ *
+ * Postings: token → set of doc ids.
+ * Token weights: token → docId → score contribution (sum of field weights).
+ * Search intersects per-token posting lists (AND semantics) and ranks by summed
+ * field weights with a bonus for prefix-only matches on the final query token.
+ */
+export class SearchIndex {
+ private docs = new Map();
+ private postings = new Map>();
+ private weights = new Map>();
+ private contentHash = '';
+ private builtAt = 0;
+
+ build(docs: IndexableDoc[]): void {
+ this.clear();
+ for (const doc of docs) this.indexDoc(doc);
+ this.contentHash = hashContent(Array.from(this.docs.values()));
+ this.builtAt = Date.now();
+ }
+
+ update(doc: IndexableDoc): void {
+ if (this.docs.has(doc.id)) this.removePostings(doc.id);
+ this.indexDoc(doc);
+ this.contentHash = hashContent(Array.from(this.docs.values()));
+ }
+
+ remove(id: string): void {
+ if (!this.docs.has(id)) return;
+ this.removePostings(id);
+ this.docs.delete(id);
+ this.contentHash = hashContent(Array.from(this.docs.values()));
+ }
+
+ clear(): void {
+ this.docs.clear();
+ this.postings.clear();
+ this.weights.clear();
+ this.contentHash = '';
+ this.builtAt = 0;
+ }
+
+ size(): number {
+ return this.docs.size;
+ }
+
+ isReady(): boolean {
+ return this.docs.size > 0;
+ }
+
+ search(query: string, opts: SearchOptions = {}): SearchHit[] {
+ const limit = opts.limit ?? DEFAULT_LIMIT;
+ const tokens = uniqueTokens(query);
+ if (tokens.length === 0) {
+ return this.applyFilters(Array.from(this.docs.values()), opts)
+ .slice(0, limit)
+ .map(doc => ({ id: doc.id, type: doc.type, score: 0, doc }));
+ }
+
+ const candidates = this.collectCandidates(tokens);
+ if (candidates.size === 0) return [];
+
+ const scored: SearchHit[] = [];
+ for (const id of candidates) {
+ const doc = this.docs.get(id);
+ if (!doc) continue;
+ if (!this.matchesFilters(doc, opts)) continue;
+ let score = 0;
+ for (const t of tokens) {
+ const w = this.weights.get(t)?.get(id) ?? 0;
+ score += w;
+ if (w === 0) {
+ score += this.prefixScore(t, id);
+ }
+ }
+ if (score > 0) scored.push({ id, type: doc.type, score, doc });
+ }
+
+ scored.sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score;
+ return a.doc.fields.title.localeCompare(b.doc.fields.title);
+ });
+ return scored.slice(0, limit);
+ }
+
+ serialize(): IndexSnapshot {
+ return {
+ version: INDEX_VERSION,
+ hash: this.contentHash,
+ builtAt: this.builtAt || Date.now(),
+ docs: Array.from(this.docs.values()),
+ };
+ }
+
+ hydrate(snapshot: IndexSnapshot): void {
+ if (snapshot.version !== INDEX_VERSION) {
+ this.clear();
+ return;
+ }
+ this.build(snapshot.docs);
+ this.builtAt = snapshot.builtAt;
+ }
+
+ getContentHash(): string {
+ return this.contentHash;
+ }
+
+ private indexDoc(doc: IndexableDoc): void {
+ this.docs.set(doc.id, doc);
+ for (const [field, text] of fieldText(doc)) {
+ const weight = FIELD_WEIGHTS[field];
+ const seen = new Set();
+ for (const token of tokenize(text)) {
+ let posting = this.postings.get(token);
+ if (!posting) {
+ posting = new Set();
+ this.postings.set(token, posting);
+ }
+ posting.add(doc.id);
+
+ let perToken = this.weights.get(token);
+ if (!perToken) {
+ perToken = new Map();
+ this.weights.set(token, perToken);
+ }
+ const prior = perToken.get(doc.id) ?? 0;
+ const bump = seen.has(token) ? Math.ceil(weight / 2) : weight;
+ perToken.set(doc.id, prior + bump);
+ seen.add(token);
+ }
+ }
+ }
+
+ private removePostings(id: string): void {
+ for (const [token, set] of this.postings) {
+ if (set.delete(id) && set.size === 0) this.postings.delete(token);
+ }
+ for (const [token, map] of this.weights) {
+ if (map.delete(id) && map.size === 0) this.weights.delete(token);
+ }
+ }
+
+ private collectCandidates(tokens: string[]): Set {
+ const lists: Set[] = [];
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i];
+ const exact = this.postings.get(token);
+ const isLast = i === tokens.length - 1;
+ if (exact && exact.size) {
+ lists.push(exact);
+ continue;
+ }
+ if (isLast) {
+ const prefixHits = this.prefixPosting(token);
+ if (prefixHits.size === 0) return new Set();
+ lists.push(prefixHits);
+ continue;
+ }
+ return new Set();
+ }
+ lists.sort((a, b) => a.size - b.size);
+ const [first, ...rest] = lists;
+ const result = new Set();
+ for (const id of first) {
+ if (rest.every(s => s.has(id))) result.add(id);
+ }
+ return result;
+ }
+
+ private prefixPosting(prefix: string): Set {
+ const out = new Set();
+ for (const [token, set] of this.postings) {
+ if (token.startsWith(prefix)) {
+ for (const id of set) out.add(id);
+ }
+ }
+ return out;
+ }
+
+ private prefixScore(prefix: string, docId: string): number {
+ let best = 0;
+ for (const [token, perToken] of this.weights) {
+ if (!token.startsWith(prefix)) continue;
+ const w = perToken.get(docId);
+ if (w && w > best) best = w;
+ }
+ return best > 0 ? best * PREFIX_BONUS : 0;
+ }
+
+ private matchesFilters(doc: IndexableDoc, opts: SearchOptions): boolean {
+ const f = opts.filters;
+ if (!f) return true;
+ if (f.type && doc.type !== f.type) return false;
+ if (f.category && doc.fields.category !== f.category) return false;
+ if (f.level && doc.fields.level !== f.level) return false;
+ return true;
+ }
+
+ private applyFilters(docs: IndexableDoc[], opts: SearchOptions): IndexableDoc[] {
+ if (!opts.filters) return docs;
+ return docs.filter(d => this.matchesFilters(d, opts));
+ }
+}
+
+export const searchIndex = new SearchIndex();
diff --git a/src/services/searchIndex/courseAdapter.ts b/src/services/searchIndex/courseAdapter.ts
new file mode 100644
index 0000000..6ad5b8b
--- /dev/null
+++ b/src/services/searchIndex/courseAdapter.ts
@@ -0,0 +1,22 @@
+import { IndexableDoc } from './types';
+import { Course } from '../../types/course';
+
+export function courseToIndexable(course: Course): IndexableDoc {
+ return {
+ id: course.id,
+ type: 'course',
+ fields: {
+ title: course.title,
+ body: course.description,
+ category: course.category,
+ level: course.level,
+ extra: course.instructor?.name,
+ },
+ payload: {
+ thumbnail: course.thumbnail,
+ totalDuration: course.totalDuration,
+ totalLessons: course.totalLessons,
+ instructorName: course.instructor?.name,
+ },
+ };
+}
diff --git a/src/services/searchIndex/index.ts b/src/services/searchIndex/index.ts
new file mode 100644
index 0000000..7d2d888
--- /dev/null
+++ b/src/services/searchIndex/index.ts
@@ -0,0 +1,5 @@
+export { SearchIndex, searchIndex } from './SearchIndex';
+export { courseToIndexable } from './courseAdapter';
+export { loadSnapshot, saveSnapshot, clearSnapshot, SNAPSHOT_KEY } from './persistence';
+export { tokenize, normalize, uniqueTokens, STOPWORDS } from './tokenize';
+export type { IndexableDoc, SearchHit, SearchOptions, IndexSnapshot } from './types';
diff --git a/src/services/searchIndex/persistence.ts b/src/services/searchIndex/persistence.ts
new file mode 100644
index 0000000..53c6305
--- /dev/null
+++ b/src/services/searchIndex/persistence.ts
@@ -0,0 +1,57 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+import { INDEX_VERSION, IndexSnapshot } from './types';
+import { appLogger } from '../../utils/logger';
+
+export const SNAPSHOT_KEY = '@teachlink_search_index_v1';
+const MAX_PAYLOAD_BYTES = 200 * 1024;
+
+export async function loadSnapshot(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(SNAPSHOT_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as IndexSnapshot;
+ if (!parsed || parsed.version !== INDEX_VERSION || !Array.isArray(parsed.docs)) {
+ return null;
+ }
+ return parsed;
+ } catch (err) {
+ appLogger.error('searchIndex: failed to load snapshot', err);
+ return null;
+ }
+}
+
+/**
+ * Save a snapshot. If the serialized payload exceeds MAX_PAYLOAD_BYTES, persist
+ * a doc-only snapshot (postings rebuilt on hydrate). Returns true on success.
+ */
+export async function saveSnapshot(snapshot: IndexSnapshot): Promise {
+ try {
+ const stripped: IndexSnapshot = {
+ version: snapshot.version,
+ hash: snapshot.hash,
+ builtAt: snapshot.builtAt,
+ docs: snapshot.docs,
+ };
+ const payload = JSON.stringify(stripped);
+ if (payload.length > MAX_PAYLOAD_BYTES) {
+ appLogger.warn(
+ `searchIndex: snapshot ${payload.length}B exceeds cap ${MAX_PAYLOAD_BYTES}B; skipping persistence`
+ );
+ return false;
+ }
+ await AsyncStorage.setItem(SNAPSHOT_KEY, payload);
+ return true;
+ } catch (err) {
+ appLogger.error('searchIndex: failed to save snapshot', err);
+ return false;
+ }
+}
+
+export async function clearSnapshot(): Promise {
+ try {
+ await AsyncStorage.removeItem(SNAPSHOT_KEY);
+ } catch (err) {
+ appLogger.error('searchIndex: failed to clear snapshot', err);
+ }
+}
diff --git a/src/services/searchIndex/tokenize.ts b/src/services/searchIndex/tokenize.ts
new file mode 100644
index 0000000..abe2412
--- /dev/null
+++ b/src/services/searchIndex/tokenize.ts
@@ -0,0 +1,48 @@
+export const STOPWORDS = new Set([
+ 'a',
+ 'an',
+ 'and',
+ 'are',
+ 'as',
+ 'at',
+ 'be',
+ 'by',
+ 'for',
+ 'from',
+ 'has',
+ 'in',
+ 'is',
+ 'it',
+ 'of',
+ 'on',
+ 'or',
+ 'that',
+ 'the',
+ 'to',
+ 'was',
+ 'were',
+ 'will',
+ 'with',
+]);
+
+const NON_ALNUM = /[^\p{L}\p{N}]+/gu;
+
+export function normalize(text: string): string {
+ return text.toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '');
+}
+
+export function tokenize(text: string): string[] {
+ if (!text) return [];
+ const normalized = normalize(text).replace(NON_ALNUM, ' ');
+ const tokens: string[] = [];
+ for (const raw of normalized.split(' ')) {
+ if (raw.length < 2) continue;
+ if (STOPWORDS.has(raw)) continue;
+ tokens.push(raw);
+ }
+ return tokens;
+}
+
+export function uniqueTokens(text: string): string[] {
+ return Array.from(new Set(tokenize(text)));
+}
diff --git a/src/services/searchIndex/types.ts b/src/services/searchIndex/types.ts
new file mode 100644
index 0000000..dd9f89d
--- /dev/null
+++ b/src/services/searchIndex/types.ts
@@ -0,0 +1,46 @@
+/**
+ * Document accepted by the search index. Adapters (e.g. courseAdapter)
+ * convert domain types into this shape.
+ */
+export interface IndexableDoc {
+ id: string;
+ type: 'course' | 'user';
+ fields: {
+ title: string;
+ body?: string;
+ category?: string;
+ level?: string;
+ extra?: string;
+ };
+ payload?: Record;
+}
+
+export interface SearchHit {
+ id: string;
+ type: 'course' | 'user';
+ score: number;
+ doc: IndexableDoc;
+}
+
+export interface SearchOptions {
+ limit?: number;
+ filters?: { category?: string; level?: string; type?: 'course' | 'user' };
+}
+
+export interface IndexSnapshot {
+ version: number;
+ hash: string;
+ builtAt: number;
+ docs: IndexableDoc[];
+ postings?: Record;
+}
+
+export const FIELD_WEIGHTS = {
+ title: 5,
+ category: 3,
+ level: 2,
+ body: 1,
+ extra: 1,
+} as const;
+
+export const INDEX_VERSION = 1;