Skip to content

Commit 3bf9ede

Browse files
patrickrbOptio Agentclaude
authored
feat(search): add notes/comment text search filter (#239)
Operators routinely record POTA/SOTA park references, contest exchanges, and personal remarks in a contact's notes field, but there was no way to search them — you could find a QSO by callsign, name, QTH, grid, band, mode, date, DXCC, or QSL status, but not by what you actually wrote down. Adds a `notes` filter that does the same case-insensitive contains match as the other text fields, wired end-to-end: the WHERE-clause builder, the GET /api/contacts/search route, and the search page (input in Advanced Filters, active-filter chip, clear-all reset). Co-authored-by: Optio Agent <optio-agent@noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 75d0226 commit 3bf9ede

4 files changed

Lines changed: 42 additions & 4 deletions

File tree

src/app/api/contacts/search/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export async function GET(request: NextRequest) {
2727
callsign: searchParams.get('callsign') || undefined,
2828
name: searchParams.get('name') || undefined,
2929
qth: searchParams.get('qth') || undefined,
30+
notes: searchParams.get('notes') || undefined,
3031
mode: searchParams.get('mode') || undefined,
3132
band: searchParams.get('band') || undefined,
3233
gridLocator: searchParams.get('gridLocator') || undefined,

src/app/search/page.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ interface SearchFilters {
5656
callsign: string;
5757
name: string;
5858
qth: string;
59+
notes: string;
5960
mode: string;
6061
band: string;
6162
gridLocator: string;
@@ -112,6 +113,15 @@ const getActiveFilterChips = (filters: SearchFilters, dxccEntities: DXCCEntity[]
112113
});
113114
}
114115

116+
if (filters.notes.trim()) {
117+
chips.push({
118+
key: 'notes',
119+
label: 'Notes',
120+
value: filters.notes,
121+
displayValue: filters.notes
122+
});
123+
}
124+
115125
if (filters.qth.trim()) {
116126
chips.push({
117127
key: 'qth',
@@ -260,6 +270,7 @@ export default function SearchPage() {
260270
callsign: '',
261271
name: '',
262272
qth: '',
273+
notes: '',
263274
mode: 'all',
264275
band: 'all',
265276
gridLocator: '',
@@ -442,6 +453,7 @@ export default function SearchPage() {
442453
callsign: '',
443454
name: '',
444455
qth: '',
456+
notes: '',
445457
mode: 'all',
446458
band: 'all',
447459
gridLocator: '',
@@ -856,7 +868,18 @@ export default function SearchPage() {
856868
<hr className="border-border" />
857869
<div className="space-y-4">
858870
<Label className="text-base font-medium">Advanced Filters</Label>
859-
871+
872+
{/* Notes / Comments */}
873+
<div className="space-y-2">
874+
<Label htmlFor="notes">Notes</Label>
875+
<Input
876+
id="notes"
877+
placeholder="e.g., POTA K-1234, contest exchange, QSL note"
878+
value={filters.notes}
879+
onChange={(e) => handleFilterChange('notes', e.target.value)}
880+
/>
881+
</div>
882+
860883
{/* Date Range */}
861884
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
862885
<div className="space-y-2">

src/lib/contact-search.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface ContactSearchFilters {
1616
callsign?: string;
1717
name?: string;
1818
qth?: string;
19+
notes?: string;
1920
mode?: string;
2021
band?: string;
2122
gridLocator?: string;
@@ -93,6 +94,12 @@ export function buildContactSearchQuery(
9394
case 'qth':
9495
conditions.push(`UPPER(qth) LIKE UPPER($${bind(`%${value}%`)})`);
9596
break;
97+
case 'notes':
98+
// Free-text remarks — POTA/SOTA references, contest exchanges, personal
99+
// notes. Contains-match like the other text fields so operators can find
100+
// a QSO by whatever they jotted down.
101+
conditions.push(`UPPER(notes) LIKE UPPER($${bind(`%${value}%`)})`);
102+
break;
96103
case 'mode':
97104
conditions.push(`UPPER(mode) = UPPER($${bind(value)})`);
98105
break;

tests/contact-search.spec.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ test.describe('buildContactSearchQuery', () => {
2525
expect(params).toEqual([1, '%w1aw%']);
2626
});
2727

28+
test('notes/comment text is matched with case-insensitive contains', () => {
29+
const { whereClause, params } = buildContactSearchQuery(1, { notes: 'POTA K-1234' });
30+
expect(whereClause).toBe('user_id = $1 AND UPPER(notes) LIKE UPPER($2)');
31+
expect(params).toEqual([1, '%POTA K-1234%']);
32+
});
33+
2834
test('mode and band match exactly (case-insensitive)', () => {
2935
const { whereClause, params } = buildContactSearchQuery(1, { mode: 'ft8', band: '20m' });
3036
expect(whereClause).toBe(
@@ -140,6 +146,7 @@ test.describe('buildContactSearchQuery', () => {
140146
callsign: 'dl',
141147
name: 'hans',
142148
qth: 'berlin',
149+
notes: 'field day',
143150
mode: 'cw',
144151
band: '15m',
145152
gridLocator: 'jo',
@@ -149,15 +156,15 @@ test.describe('buildContactSearchQuery', () => {
149156
dxcc: '230',
150157
});
151158

152-
// 10 filters supplied, but qslStatus binds no value → 9 bound params + userId.
159+
// 11 filters supplied, but qslStatus binds no value → 10 bound params + userId.
153160
expect(params).toEqual([
154-
42, '%dl%', '%hans%', '%berlin%', 'cw', '15m', '%jo%',
161+
42, '%dl%', '%hans%', '%berlin%', '%field day%', 'cw', '15m', '%jo%',
155162
'2024-06-01', '2024-06-30', 230,
156163
]);
157164

158165
const referenced = [...whereClause.matchAll(/\$(\d+)/g)].map(m => Number(m[1]));
159166
// Placeholders must be exactly $1..$params.length with no gaps or overshoot.
160-
expect(referenced).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
167+
expect(referenced).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
161168
expect(Math.max(...referenced)).toBe(params.length);
162169
});
163170
});

0 commit comments

Comments
 (0)