Skip to content

Commit 4e9e1ce

Browse files
authored
Merge pull request #110 from profullstack/feat/watchlist-bulk-add
feat(finance): bulk-add tickers to watchlist from a pasted list
2 parents a884a53 + 5d375c1 commit 4e9e1ce

4 files changed

Lines changed: 158 additions & 2 deletions

File tree

src/app/api/finance/watchlist/route.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { requireActiveSubscription } from '@/lib/subscription/guard';
1414
import { getActiveProfileId } from '@/lib/profiles/profile-utils';
1515
import { getServerClient } from '@/lib/supabase';
1616
import { normalizeSymbol } from '@/lib/finance/market-data/stooq';
17+
import { parseSymbolList } from '@/lib/finance/watchlist';
1718

1819
export const dynamic = 'force-dynamic';
1920

@@ -51,7 +52,34 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
5152
return NextResponse.json({ error: 'No active profile' }, { status: 400 });
5253
}
5354

54-
const body = (await request.json().catch(() => null)) as { symbol?: string; exchange?: string } | null;
55+
const body = (await request.json().catch(() => null)) as
56+
| { symbol?: string; symbols?: string | string[]; exchange?: string }
57+
| null;
58+
59+
// Bulk add: `symbols` may be a comma/space/newline-separated string or array.
60+
if (body?.symbols !== undefined) {
61+
const { valid, invalid } = parseSymbolList(body.symbols);
62+
if (valid.length === 0) {
63+
return NextResponse.json({ error: 'no valid symbols', invalid }, { status: 400 });
64+
}
65+
66+
const { data, error } = await getServerClient()
67+
.from('finance_watchlist')
68+
.upsert(
69+
valid.map((symbol) => ({ profile_id: profileId, symbol, exchange: null })),
70+
{ onConflict: 'profile_id,symbol' },
71+
)
72+
.select('id, symbol, exchange, added_at');
73+
74+
if (error) {
75+
console.error('[finance/watchlist] bulk add error:', error);
76+
return NextResponse.json({ error: 'failed to add symbols' }, { status: 500 });
77+
}
78+
79+
return NextResponse.json({ added: data ?? [], count: data?.length ?? 0, invalid }, { status: 201 });
80+
}
81+
82+
// Single add.
5583
const symbol = normalizeSymbol(body?.symbol ?? '');
5684
if (!SYMBOL_RE.test(symbol)) {
5785
return NextResponse.json({ error: 'invalid symbol' }, { status: 400 });

src/app/finance/finance-hub.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export function FinanceHub(): React.ReactElement {
2323
const [query, setQuery] = useState('');
2424
const [watchlist, setWatchlist] = useState<WatchlistRow[]>([]);
2525
const [recent, setRecent] = useState<string[]>([]);
26+
const [bulk, setBulk] = useState('');
27+
const [bulkBusy, setBulkBusy] = useState(false);
28+
const [bulkMsg, setBulkMsg] = useState<string | null>(null);
2629

2730
useEffect(() => {
2831
try {
@@ -33,13 +36,51 @@ export function FinanceHub(): React.ReactElement {
3336
}
3437
}, []);
3538

36-
useEffect(() => {
39+
const loadWatchlist = useCallback(() => {
3740
fetch('/api/finance/watchlist', { cache: 'no-store' })
3841
.then((res) => (res.ok ? res.json() : { watchlist: [] }))
3942
.then((body: { watchlist?: WatchlistRow[] }) => setWatchlist(body.watchlist ?? []))
4043
.catch(() => undefined);
4144
}, []);
4245

46+
useEffect(() => {
47+
loadWatchlist();
48+
}, [loadWatchlist]);
49+
50+
const addBulk = useCallback(
51+
async (e: React.FormEvent) => {
52+
e.preventDefault();
53+
if (!bulk.trim()) return;
54+
setBulkBusy(true);
55+
setBulkMsg(null);
56+
try {
57+
const res = await fetch('/api/finance/watchlist', {
58+
method: 'POST',
59+
headers: { 'content-type': 'application/json' },
60+
body: JSON.stringify({ symbols: bulk }),
61+
});
62+
const body = await res.json().catch(() => ({}));
63+
if (!res.ok) {
64+
setBulkMsg(body.error === 'no valid symbols' ? 'No valid tickers found.' : 'Could not add tickers.');
65+
return;
66+
}
67+
const added = body.count ?? 0;
68+
const invalid: string[] = body.invalid ?? [];
69+
setBulkMsg(
70+
`Added ${added} ticker${added === 1 ? '' : 's'}` +
71+
(invalid.length ? ` · skipped ${invalid.length} invalid (${invalid.slice(0, 5).join(', ')})` : ''),
72+
);
73+
setBulk('');
74+
loadWatchlist();
75+
} catch {
76+
setBulkMsg('Network error.');
77+
} finally {
78+
setBulkBusy(false);
79+
}
80+
},
81+
[bulk, loadWatchlist],
82+
);
83+
4384
const go = useCallback(
4485
(raw: string) => {
4586
const symbol = normalizeSymbol(raw);
@@ -99,6 +140,22 @@ export function FinanceHub(): React.ReactElement {
99140
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-text-muted">
100141
Watchlist
101142
</h2>
143+
144+
<form onSubmit={addBulk} className="mb-4 flex flex-col gap-2 sm:flex-row">
145+
<input
146+
className="input flex-1"
147+
placeholder="Paste tickers to add, e.g. NVDA, AAPL, TSLA, SPY"
148+
value={bulk}
149+
onChange={(e) => setBulk(e.target.value)}
150+
autoCapitalize="characters"
151+
spellCheck={false}
152+
/>
153+
<button type="submit" disabled={bulkBusy || !bulk.trim()} className="btn btn-secondary disabled:opacity-60">
154+
{bulkBusy ? 'Adding…' : 'Add all'}
155+
</button>
156+
</form>
157+
{bulkMsg ? <p className="mb-3 text-xs text-text-muted">{bulkMsg}</p> : null}
158+
102159
{watchlist.length === 0 ? (
103160
<p className="text-sm text-text-muted">
104161
No tickers yet. Open a ticker and tap “Add to watchlist”.

src/lib/finance/watchlist.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { parseSymbolList } from './watchlist';
3+
4+
describe('parseSymbolList', () => {
5+
it('parses a comma-separated string, normalizing + de-duping', () => {
6+
const { valid, invalid } = parseSymbolList(' nvda, AAPL ,tsla, nvda ');
7+
expect(valid).toEqual(['NVDA', 'AAPL', 'TSLA']);
8+
expect(invalid).toEqual([]);
9+
});
10+
11+
it('splits on whitespace, newlines and semicolons too', () => {
12+
const { valid } = parseSymbolList('NVDA AAPL\nMSFT;GOOG\tSPY');
13+
expect(valid).toEqual(['NVDA', 'AAPL', 'MSFT', 'GOOG', 'SPY']);
14+
});
15+
16+
it('accepts an array input', () => {
17+
expect(parseSymbolList(['nvda', 'aapl']).valid).toEqual(['NVDA', 'AAPL']);
18+
});
19+
20+
it('collects invalid tokens separately', () => {
21+
const { valid, invalid } = parseSymbolList('NVDA, $$$, 123, , TSLA');
22+
expect(valid).toEqual(['NVDA', 'TSLA']);
23+
expect(invalid).toContain('$$$');
24+
expect(invalid).toContain('123');
25+
});
26+
27+
it('returns empty for empty input', () => {
28+
expect(parseSymbolList(' ')).toEqual({ valid: [], invalid: [] });
29+
});
30+
});

src/lib/finance/watchlist.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Finance — watchlist helpers.
3+
*/
4+
5+
import { normalizeSymbol } from './market-data/stooq';
6+
7+
const SYMBOL_RE = /^[A-Z][A-Z0-9.\-]{0,9}$/;
8+
9+
export interface ParsedSymbolList {
10+
/** Valid, normalized, de-duplicated symbols. */
11+
valid: string[];
12+
/** Raw tokens that failed validation (for user feedback). */
13+
invalid: string[];
14+
}
15+
16+
/**
17+
* Parse a pasted list of tickers (comma / whitespace / newline / semicolon
18+
* separated, or an array) into validated, normalized, de-duplicated symbols.
19+
*/
20+
export function parseSymbolList(input: string | string[]): ParsedSymbolList {
21+
const tokens = Array.isArray(input) ? input : input.split(/[\s,;]+/);
22+
const seen = new Set<string>();
23+
const valid: string[] = [];
24+
const invalid: string[] = [];
25+
26+
for (const token of tokens) {
27+
const trimmed = token.trim();
28+
if (!trimmed) continue;
29+
const symbol = normalizeSymbol(trimmed);
30+
if (SYMBOL_RE.test(symbol)) {
31+
if (!seen.has(symbol)) {
32+
seen.add(symbol);
33+
valid.push(symbol);
34+
}
35+
} else {
36+
invalid.push(trimmed);
37+
}
38+
}
39+
40+
return { valid, invalid };
41+
}

0 commit comments

Comments
 (0)