From 9cc9cf3888de9fb987524e02b1ac6787ad8fb56e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 18:22:30 +0000 Subject: [PATCH] feat(finance): import/export watchlists as comma-separated tickers Export downloads the active list as alphabetical, comma-separated tickers (`.csv`); Import reads a .csv/.txt file and adds its symbols to the active list through the existing bulk-add endpoint, so validation/normalization stay in one place. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/finance/watchlist-section.tsx | 101 +++++++++++++++++++++----- src/lib/finance/watchlist.test.ts | 38 +++++++++- src/lib/finance/watchlist.ts | 23 ++++++ 3 files changed, 143 insertions(+), 19 deletions(-) diff --git a/src/app/finance/watchlist-section.tsx b/src/app/finance/watchlist-section.tsx index 12528e93..2f3da508 100644 --- a/src/app/finance/watchlist-section.tsx +++ b/src/app/finance/watchlist-section.tsx @@ -9,12 +9,12 @@ * (sparklines / changes / quotes) are scoped to the active list's symbols. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import Link from 'next/link'; import { Sparkline } from '@/components/finance/sparkline'; import { MarketSessionBadge } from '@/components/finance/market-session'; import { useVisibleInterval } from '@/lib/finance/use-visible-interval'; -import { MAX_WATCHLIST_NAME } from '@/lib/finance/watchlist'; +import { MAX_WATCHLIST_NAME, formatSymbolsCsv, watchlistExportFilename } from '@/lib/finance/watchlist'; import type { WatchlistChanges } from '@/lib/finance/performance'; import type { Quote } from '@/lib/finance/market-data/types'; @@ -63,6 +63,7 @@ export function WatchlistSection(): React.ReactElement { const [bulk, setBulk] = useState(''); const [bulkBusy, setBulkBusy] = useState(false); const [bulkMsg, setBulkMsg] = useState(null); + const importInputRef = useRef(null); // List CRUD UI state. const [newName, setNewName] = useState(''); @@ -212,10 +213,10 @@ export function WatchlistSection(): React.ReactElement { }, [activeId, activeList, loadLists]); // --- Add tickers ---------------------------------------------------------- - const addBulk = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!bulk.trim()) return; + /** Send a pasted / imported ticker blob to the bulk-add endpoint. */ + const submitSymbols = useCallback( + async (text: string, verb: 'Added' | 'Imported'): Promise => { + if (!text.trim()) return false; setBulkBusy(true); setBulkMsg(null); try { @@ -224,31 +225,67 @@ export function WatchlistSection(): React.ReactElement { headers: { 'content-type': 'application/json' }, // activeId may be null for a brand-new user — the server then creates // (and returns) the default list, which we adopt below. - body: JSON.stringify(activeId ? { symbols: bulk, watchlistId: activeId } : { symbols: bulk }), + body: JSON.stringify(activeId ? { symbols: text, watchlistId: activeId } : { symbols: text }), }); const body = await res.json().catch(() => ({})); if (!res.ok) { setBulkMsg(body.error === 'no valid symbols' ? 'No valid tickers found.' : 'Could not add tickers.'); - return; + return false; } const added = body.count ?? 0; const invalid: string[] = body.invalid ?? []; setBulkMsg( - `Added ${added} ticker${added === 1 ? '' : 's'}` + + `${verb} ${added} ticker${added === 1 ? '' : 's'}` + (invalid.length ? ` · skipped ${invalid.length} invalid (${invalid.slice(0, 5).join(', ')})` : ''), ); - setBulk(''); const next = await loadLists(); const targetId = (body.watchlistId as string) ?? activeId ?? next[0]?.id ?? null; setActiveId(targetId); if (targetId === activeId) loadItems(); + return true; } catch { setBulkMsg('Network error.'); + return false; } finally { setBulkBusy(false); } }, - [bulk, activeId, loadLists, loadItems], + [activeId, loadLists, loadItems], + ); + + const addBulk = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (await submitSymbols(bulk, 'Added')) setBulk(''); + }, + [bulk, submitSymbols], + ); + + // --- Import / export ------------------------------------------------------ + /** Download the active list as comma-separated, alphabetical tickers. */ + const exportList = useCallback(() => { + const csv = formatSymbolsCsv(watchlist.map((row) => row.symbol)); + if (!csv) return; + const url = URL.createObjectURL(new Blob([`${csv}\n`], { type: 'text/csv' })); + const link = document.createElement('a'); + link.href = url; + link.download = watchlistExportFilename(activeList?.name ?? 'watchlist'); + link.click(); + URL.revokeObjectURL(url); + }, [watchlist, activeList]); + + /** Read a comma-separated ticker file and add its symbols to the active list. */ + const importFile = useCallback( + async (file: File | undefined) => { + if (!file) return; + const text = await file.text().catch(() => ''); + if (!text.trim()) { + setBulkMsg('That file was empty.'); + return; + } + await submitSymbols(text, 'Imported'); + }, + [submitSymbols], ); const removeSymbol = useCallback( @@ -328,10 +365,10 @@ export function WatchlistSection(): React.ReactElement { - {/* Active-list controls: rename / delete */} - {activeList ? ( -
- {renaming ? ( + {/* Active-list controls: rename / delete / import / export */} +
+ {activeList ? ( + renaming ? ( - )} -
- ) : null} + ) + ) : null} + + + + { + const file = e.target.files?.[0]; + e.target.value = ''; // allow re-importing the same file + void importFile(file); + }} + /> +
{ it('parses a comma-separated string, normalizing + de-duping', () => { @@ -29,6 +35,36 @@ describe('parseSymbolList', () => { }); }); +describe('formatSymbolsCsv', () => { + it('sorts alphabetically and joins with commas', () => { + expect(formatSymbolsCsv(['TSLA', 'AAPL', 'NVDA'])).toBe('AAPL,NVDA,TSLA'); + }); + + it('normalizes and de-dupes', () => { + expect(formatSymbolsCsv([' nvda ', 'NVDA', 'aapl'])).toBe('AAPL,NVDA'); + }); + + it('returns an empty string for an empty list', () => { + expect(formatSymbolsCsv([])).toBe(''); + }); + + it('round-trips through parseSymbolList', () => { + const csv = formatSymbolsCsv(['spy', 'AAPL', 'brk-b']); + expect(csv).toBe('AAPL,BRK-B,SPY'); + expect(parseSymbolList(csv).valid).toEqual(['AAPL', 'BRK-B', 'SPY']); + }); +}); + +describe('watchlistExportFilename', () => { + it('slugifies the list name', () => { + expect(watchlistExportFilename('My Tech List')).toBe('my-tech-list.csv'); + }); + + it('falls back when the name has no usable characters', () => { + expect(watchlistExportFilename(' *** ')).toBe('watchlist.csv'); + }); +}); + describe('sanitizeWatchlistName', () => { it('trims and collapses internal whitespace', () => { expect(sanitizeWatchlistName(' My Tech List ')).toBe('My Tech List'); diff --git a/src/lib/finance/watchlist.ts b/src/lib/finance/watchlist.ts index 42212705..d3a8a888 100644 --- a/src/lib/finance/watchlist.ts +++ b/src/lib/finance/watchlist.ts @@ -22,6 +22,29 @@ export function sanitizeWatchlistName(raw: unknown): string | null { return name.length > 0 ? name : null; } +/** + * Render symbols as the export format: comma-separated, alphabetical, deduped. + */ +export function formatSymbolsCsv(symbols: string[]): string { + const seen = new Set(); + for (const raw of symbols) { + const symbol = normalizeSymbol(raw); + if (symbol) seen.add(symbol); + } + return [...seen].sort().join(','); +} + +/** File name for an exported list, e.g. "My Tech List" -> "my-tech-list.csv". */ +export function watchlistExportFilename(name: string): string { + const slug = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, MAX_WATCHLIST_NAME); + return `${slug || 'watchlist'}.csv`; +} + export interface ParsedSymbolList { /** Valid, normalized, de-duplicated symbols. */ valid: string[];