Skip to content

Commit 03ab045

Browse files
authored
feat(web): enhance search functionality with scoring system (#146)
2 parents 8330cc3 + b7dd964 commit 03ab045

1 file changed

Lines changed: 127 additions & 41 deletions

File tree

apps/web/src/hooks/use-search.ts

Lines changed: 127 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,74 +6,160 @@ export interface UseSearchOptions<T> {
66
searchFields: (item: T) => string[];
77
}
88

9+
// Scoring constants used to rank matches
10+
const SCORE = {
11+
normalizedExact: 1000,
12+
exact: 900,
13+
startsWith: 700,
14+
wordBoundary: 500,
15+
contains: 300,
16+
normalizedContains: 100,
17+
phraseExact: 2500,
18+
phraseNormalized: 2200,
19+
phraseStartsWith: 300,
20+
} as const;
21+
22+
interface FieldData {
23+
lowerFields: string[];
24+
normalizedFields: string[];
25+
weights: number[];
26+
searchableText: string;
27+
normalizedText: string;
28+
}
29+
930
export function useSearch<T>({
1031
items,
1132
searchQuery,
1233
searchFields,
1334
}: UseSearchOptions<T>) {
1435
return useMemo(() => {
15-
if (!searchQuery.trim()) {
16-
return items;
17-
}
36+
const trimmedQuery = searchQuery.trim();
37+
if (!trimmedQuery) return items;
1838

19-
const query = searchQuery.toLowerCase().trim();
39+
const query = trimmedQuery.toLowerCase();
2040

2141
// Split by spaces and dashes to create search terms
2242
const searchTerms = query
2343
.split(/[\s-]+/)
2444
.filter((term) => term.length > 0)
2545
.map((term) => term.toLowerCase());
2646

27-
if (searchTerms.length === 0) {
28-
return items;
29-
}
47+
if (searchTerms.length === 0) return items;
3048

31-
return items.filter((item) => {
32-
// Get searchable fields for this item
33-
const searchableFields = searchFields(item).map((field) =>
34-
field.toLowerCase()
35-
);
49+
// Helpers
50+
const escapeRegex = (value: string) =>
51+
value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
52+
const normalizeCompact = (value: string) => value.replace(/[\s-_]/g, "");
3653

37-
const searchableText = searchableFields.join(" ");
54+
const buildFieldData = (fields: string[]): FieldData => {
55+
const lowerFields = fields.map((f) => f.toLowerCase());
56+
const normalizedFields = lowerFields.map((f) => normalizeCompact(f));
57+
const weights = fields.map((_, index) => fields.length - index);
58+
return {
59+
lowerFields,
60+
normalizedFields,
61+
weights,
62+
searchableText: lowerFields.join(" "),
63+
normalizedText: normalizedFields.join(" "),
64+
};
65+
};
3866

39-
// Create normalized versions (without spaces, dashes, underscores) for better matching
40-
const normalizedFields = searchableFields.map((field) =>
41-
field.replace(/[\s-_]/g, "").toLowerCase()
42-
);
43-
const normalizedSearchableText = normalizedFields.join(" ");
67+
const normalizedQuery = normalizeCompact(query);
4468

45-
// Check if all search terms are found (AND logic for better precision)
69+
const matchesAllTerms = (data: FieldData): boolean => {
4670
return searchTerms.every((term) => {
47-
const normalizedTerm = term.replace(/[\s-_]/g, "").toLowerCase();
48-
49-
// Check direct substring match in any field
50-
if (searchableText.includes(term)) {
51-
return true;
52-
}
71+
const normalizedTerm = normalizeCompact(term);
72+
if (data.searchableText.includes(term)) return true;
73+
if (data.normalizedText.includes(normalizedTerm)) return true;
5374

54-
// Check normalized match (handles "tostring" matching "To String")
55-
if (normalizedSearchableText.includes(normalizedTerm)) {
75+
const wordBoundaryRegex = new RegExp(`\\b${escapeRegex(term)}`, "i");
76+
if (data.lowerFields.some((field) => wordBoundaryRegex.test(field))) {
5677
return true;
5778
}
5879

59-
// Check if term matches word boundaries (more precise matching)
60-
const wordBoundaryRegex = new RegExp(
61-
`\\b${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
80+
const normWordBoundaryRegex = new RegExp(
81+
`\\b${escapeRegex(normalizedTerm)}`,
6282
"i"
6383
);
64-
if (searchableFields.some((field) => wordBoundaryRegex.test(field))) {
65-
return true;
84+
return data.normalizedFields.some((field) =>
85+
normWordBoundaryRegex.test(field)
86+
);
87+
});
88+
};
89+
90+
const computeItemScore = (data: FieldData): number => {
91+
let totalScore = 0;
92+
93+
for (const term of searchTerms) {
94+
const normalizedTerm = normalizeCompact(term);
95+
for (let i = 0; i < data.lowerFields.length; i++) {
96+
const field = data.lowerFields[i];
97+
const normField = data.normalizedFields[i];
98+
const weight = data.weights[i];
99+
100+
if (normField === normalizedTerm) {
101+
totalScore += SCORE.normalizedExact * weight;
102+
continue;
103+
}
104+
if (field === term) {
105+
totalScore += SCORE.exact * weight;
106+
continue;
107+
}
108+
if (field.startsWith(term)) {
109+
totalScore += SCORE.startsWith * weight;
110+
continue;
111+
}
112+
const wordBoundaryRegex = new RegExp(`\\b${escapeRegex(term)}`, "i");
113+
if (wordBoundaryRegex.test(field)) {
114+
totalScore += SCORE.wordBoundary * weight;
115+
continue;
116+
}
117+
if (field.includes(term)) {
118+
totalScore += SCORE.contains * weight;
119+
continue;
120+
}
121+
if (normField.includes(normalizedTerm)) {
122+
totalScore += SCORE.normalizedContains * weight;
123+
continue;
124+
}
66125
}
126+
}
67127

68-
// Check normalized word boundaries for individual fields
69-
const normalizedWordBoundaryRegex = new RegExp(
70-
`\\b${normalizedTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
71-
"i"
72-
);
73-
return normalizedFields.some((field) =>
74-
normalizedWordBoundaryRegex.test(field)
75-
);
128+
for (let i = 0; i < data.lowerFields.length; i++) {
129+
const field = data.lowerFields[i];
130+
const normField = data.normalizedFields[i];
131+
const weight = data.weights[i];
132+
133+
if (field.includes(query)) {
134+
totalScore += SCORE.phraseExact * weight;
135+
} else if (normField.includes(normalizedQuery)) {
136+
totalScore += SCORE.phraseNormalized * weight;
137+
}
138+
139+
if (field.startsWith(query)) {
140+
totalScore += SCORE.phraseStartsWith * weight;
141+
}
142+
}
143+
144+
return totalScore;
145+
};
146+
147+
// First, determine which items match (AND across terms), then rank them
148+
const scoredMatches = items
149+
.map((item, index) => {
150+
const fields = searchFields(item);
151+
const fieldData = buildFieldData(fields);
152+
const score = matchesAllTerms(fieldData)
153+
? computeItemScore(fieldData)
154+
: 0;
155+
return { item, score, index };
156+
})
157+
.filter(({ score }) => score > 0)
158+
.sort((a, b) => {
159+
if (b.score !== a.score) return b.score - a.score;
160+
return a.index - b.index;
76161
});
77-
});
162+
163+
return scoredMatches.map(({ item }) => item);
78164
}, [items, searchQuery, searchFields]);
79165
}

0 commit comments

Comments
 (0)