From c6a771cfa69d0ae3d78d0f5058c45aed0b066bf7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 22:24:41 +0000 Subject: [PATCH] feat(finance): export every watchlist to one file, and import it back "Export all" downloads watchlists.csv: a `#watchlists` sentinel line then one `Name,TICKER,...` line per list, lists sorted by name and tickers alphabetical. List names containing commas or quotes are CSV-quoted. Import recognizes the sentinel and restores each line as its own list, so the file round-trips; without it a multi-line ticker file would be ambiguous (is the first field a name or a symbol?). Plain ticker files keep the existing behavior of becoming one list named after the file. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/finance/watchlist-section.tsx | 163 ++++++++++++++++++++++---- src/lib/finance/watchlist.test.ts | 50 ++++++++ src/lib/finance/watchlist.ts | 80 +++++++++++++ 3 files changed, 267 insertions(+), 26 deletions(-) diff --git a/src/app/finance/watchlist-section.tsx b/src/app/finance/watchlist-section.tsx index ad69555..47cc53d 100644 --- a/src/app/finance/watchlist-section.tsx +++ b/src/app/finance/watchlist-section.tsx @@ -16,12 +16,16 @@ import { MarketSessionBadge } from '@/components/finance/market-session'; import { useVisibleInterval } from '@/lib/finance/use-visible-interval'; import { MAX_WATCHLIST_NAME, + WATCHLISTS_EXPORT_FILENAME, formatSymbolsCsv, + formatWatchlistsExport, parseSymbolList, + parseWatchlistsExport, uniqueWatchlistName, watchlistExportFilename, watchlistNameFromFilename, } from '@/lib/finance/watchlist'; +import type { NamedWatchlist } from '@/lib/finance/watchlist'; import type { WatchlistChanges } from '@/lib/finance/performance'; import type { Quote } from '@/lib/finance/market-data/types'; @@ -272,23 +276,120 @@ export function WatchlistSection(): React.ReactElement { ); // --- 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' })); + /** Hand the browser a generated file. */ + const download = useCallback((text: string, filename: string) => { + const url = URL.createObjectURL(new Blob([`${text}\n`], { type: 'text/csv' })); const link = document.createElement('a'); link.href = url; - link.download = watchlistExportFilename(activeList?.name ?? 'watchlist'); + link.download = filename; link.click(); URL.revokeObjectURL(url); - }, [watchlist, activeList]); + }, []); + + /** Download the active list as comma-separated, alphabetical tickers. */ + const exportList = useCallback(() => { + const csv = formatSymbolsCsv(watchlist.map((row) => row.symbol)); + if (!csv) return; + download(csv, watchlistExportFilename(activeList?.name ?? 'watchlist')); + }, [watchlist, activeList, download]); /** - * Read a comma-separated ticker file into a *new* list named after the file - * ("my-tech-list.csv" -> "My Tech List", suffixed if that name is taken). - * The symbols are validated client-side first so a junk file never leaves an - * empty list behind; if list creation fails we fall back to the active list. + * Download every list in one file — one `Name,TICKER,…` line per list. Each + * list's tickers are fetched on demand, since only the active list's items + * are held in state. + */ + const exportAllLists = useCallback(async () => { + if (lists.length === 0) return; + setBulkBusy(true); + setBulkMsg(null); + try { + const named = await Promise.all( + lists.map(async (list) => { + const res = await fetch(`/api/finance/watchlist?watchlistId=${encodeURIComponent(list.id)}`, { + cache: 'no-store', + }); + const body = (res.ok ? await res.json() : { watchlist: [] }) as { watchlist?: WatchlistRow[] }; + return { name: list.name, symbols: (body.watchlist ?? []).map((row) => row.symbol) }; + }), + ); + download(formatWatchlistsExport(named), WATCHLISTS_EXPORT_FILENAME); + const total = named.reduce((sum, l) => sum + l.symbols.length, 0); + setBulkMsg(`Exported ${named.length} list${named.length === 1 ? '' : 's'} · ${total} tickers.`); + } catch { + setBulkMsg('Could not export your lists.'); + } finally { + setBulkBusy(false); + } + }, [lists, download]); + + /** Create a list and return its id, or undefined when the server refuses. */ + const createNamedList = useCallback(async (name: string): Promise => { + try { + const res = await fetch('/api/finance/watchlists', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (!res.ok) return undefined; + const body = (await res.json()) as { watchlist: WatchlistSummary }; + return body.watchlist.id; + } catch { + return undefined; + } + }, []); + + /** + * Restore an all-lists export: one new list per line, names suffixed as + * needed so an existing "Tech" isn't merged into. + */ + const importBundle = useCallback( + async (bundle: NamedWatchlist[]) => { + setBulkBusy(true); + setBulkMsg(null); + const taken = lists.map((l) => l.name); + let created = 0; + let tickers = 0; + let lastId: string | undefined; + try { + for (const entry of bundle) { + const name = uniqueWatchlistName(entry.name, taken); + taken.push(name); + const id = await createNamedList(name); + if (!id) continue; + created += 1; + lastId = id; + if (entry.symbols.length === 0) continue; + const res = await fetch('/api/finance/watchlist', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ symbols: entry.symbols, watchlistId: id }), + }); + const body = await res.json().catch(() => ({})); + if (res.ok) tickers += body.count ?? 0; + } + setBulkMsg( + created === 0 + ? 'Could not import those lists.' + : `Imported ${created} list${created === 1 ? '' : 's'} · ${tickers} ticker${tickers === 1 ? '' : 's'}.`, + ); + await loadLists(); + if (lastId) setActiveId(lastId); + } catch { + setBulkMsg('Network error.'); + } finally { + setBulkBusy(false); + } + }, + [lists, createNamedList, loadLists], + ); + + /** + * Read a ticker file. An all-lists export (recognized by its header line) is + * restored list-by-list; anything else becomes a *new* list named after the + * file ("my-tech-list.csv" -> "My Tech List", suffixed if that name is + * taken). Symbols are validated client-side first so a junk file never + * leaves an empty list behind; if list creation fails we fall back to the + * active list. */ const importFile = useCallback( async (file: File | undefined) => { @@ -298,6 +399,17 @@ export function WatchlistSection(): React.ReactElement { setBulkMsg('That file was empty.'); return; } + + const bundle = parseWatchlistsExport(text); + if (bundle) { + if (bundle.length === 0) { + setBulkMsg('That file has no lists in it.'); + return; + } + await importBundle(bundle); + return; + } + if (parseSymbolList(text).valid.length === 0) { setBulkMsg('No valid tickers found.'); return; @@ -307,23 +419,12 @@ export function WatchlistSection(): React.ReactElement { watchlistNameFromFilename(file.name), lists.map((l) => l.name), ); - let newId: string | undefined; - try { - const res = await fetch('/api/finance/watchlists', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name }), - }); - if (res.ok) { - const body = (await res.json()) as { watchlist: WatchlistSummary }; - newId = body.watchlist.id; - } - } catch { - // fall through — import into the active list instead - } + // On failure newId stays undefined and the import falls back to the + // active list rather than being lost. + const newId = await createNamedList(name); await submitSymbols(text, 'Imported', newId); }, - [lists, submitSymbols], + [lists, createNamedList, importBundle, submitSymbols], ); const removeSymbol = useCallback( @@ -458,10 +559,20 @@ export function WatchlistSection(): React.ReactElement { type="button" onClick={exportList} disabled={watchlist.length === 0} + title="Download this list's tickers" className="text-text-muted hover:text-text-secondary hover:underline disabled:opacity-40" > Export + { }); }); +describe('formatWatchlistsExport / parseWatchlistsExport', () => { + const lists = [ + { name: 'Tech', symbols: ['NVDA', 'AAPL'] }, + { name: 'Energy', symbols: ['XOM'] }, + ]; + + it('writes a header, one sorted line per list, tickers alphabetical', () => { + expect(formatWatchlistsExport(lists)).toBe(['#watchlists', 'Energy,XOM', 'Tech,AAPL,NVDA'].join('\n')); + }); + + it('round-trips through the parser', () => { + const parsed = parseWatchlistsExport(formatWatchlistsExport(lists)); + expect(parsed).toEqual([ + { name: 'Energy', symbols: ['XOM'] }, + { name: 'Tech', symbols: ['AAPL', 'NVDA'] }, + ]); + }); + + it('quotes and round-trips names containing commas or quotes', () => { + const tricky = [{ name: 'Big, "risky" names', symbols: ['SPY'] }]; + const text = formatWatchlistsExport(tricky); + expect(text).toContain('"Big, ""risky"" names",SPY'); + expect(parseWatchlistsExport(text)).toEqual(tricky); + }); + + it('keeps empty lists', () => { + const text = formatWatchlistsExport([{ name: 'Empty', symbols: [] }]); + expect(text).toBe('#watchlists\nEmpty'); + expect(parseWatchlistsExport(text)).toEqual([{ name: 'Empty', symbols: [] }]); + }); + + it('returns null for a plain ticker list, so it imports as a single list', () => { + expect(parseWatchlistsExport('AAPL,NVDA,SPY')).toBeNull(); + expect(parseWatchlistsExport('AAPL\nNVDA')).toBeNull(); + expect(parseWatchlistsExport('')).toBeNull(); + }); + + it('tolerates blank lines and CRLF', () => { + expect(parseWatchlistsExport('#watchlists\r\n\r\nTech,AAPL\r\n')).toEqual([{ name: 'Tech', symbols: ['AAPL'] }]); + }); + + it('skips junk tickers within a line', () => { + expect(parseWatchlistsExport('#watchlists\nTech,AAPL,$$$,NVDA')).toEqual([ + { name: 'Tech', symbols: ['AAPL', 'NVDA'] }, + ]); + }); +}); + describe('watchlistNameFromFilename', () => { it('round-trips an exported file name back to the list name', () => { const file = watchlistExportFilename('My Tech List'); diff --git a/src/lib/finance/watchlist.ts b/src/lib/finance/watchlist.ts index d96b553..3190936 100644 --- a/src/lib/finance/watchlist.ts +++ b/src/lib/finance/watchlist.ts @@ -34,6 +34,86 @@ export function formatSymbolsCsv(symbols: string[]): string { return [...seen].sort().join(','); } +/** + * First line of an all-lists export. Import keys off this sentinel to tell a + * multi-list file from a plain ticker list — without it, a multi-line ticker + * file would be ambiguous (is the first field a list name or a symbol?). + */ +export const WATCHLISTS_EXPORT_HEADER = '#watchlists'; + +/** File name for the all-lists export. */ +export const WATCHLISTS_EXPORT_FILENAME = 'watchlists.csv'; + +export interface NamedWatchlist { + name: string; + symbols: string[]; +} + +/** Quote a list name if it would otherwise collide with the field separator. */ +function quoteName(name: string): string { + return /["\n,]/.test(name) ? `"${name.replace(/"/g, '""')}"` : name; +} + +/** Split a line into its (possibly quoted) leading name and the rest. */ +function splitNameAndRest(line: string): [string, string] { + if (!line.startsWith('"')) { + const comma = line.indexOf(','); + return comma === -1 ? [line, ''] : [line.slice(0, comma), line.slice(comma + 1)]; + } + let name = ''; + let i = 1; + while (i < line.length) { + if (line[i] === '"') { + if (line[i + 1] === '"') { + name += '"'; + i += 2; + continue; + } + i += 1; + break; + } + name += line[i]; + i += 1; + } + // `i` now sits just past the closing quote; the rest follows its comma. + return [name, line[i] === ',' ? line.slice(i + 1) : '']; +} + +/** + * Render every list as one file: a sentinel line, then one line per list — + * `Name,TICKER,TICKER,…` with tickers alphabetical and the lists themselves + * sorted by name. Empty lists are kept so the file round-trips exactly. + */ +export function formatWatchlistsExport(lists: NamedWatchlist[]): string { + const lines = [...lists] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((list) => { + const csv = formatSymbolsCsv(list.symbols); + return csv ? `${quoteName(list.name)},${csv}` : quoteName(list.name); + }); + return [WATCHLISTS_EXPORT_HEADER, ...lines].join('\n'); +} + +/** + * Parse an all-lists export. Returns null when the sentinel is absent, i.e. + * the file is a plain ticker list and the caller should treat it as one list. + */ +export function parseWatchlistsExport(text: string): NamedWatchlist[] | null { + const lines = text.split(/\r?\n/); + const first = lines.findIndex((line) => line.trim().length > 0); + if (first === -1 || lines[first].trim().toLowerCase() !== WATCHLISTS_EXPORT_HEADER) return null; + + const out: NamedWatchlist[] = []; + for (const line of lines.slice(first + 1)) { + if (!line.trim()) continue; + const [rawName, rest] = splitNameAndRest(line.trim()); + const name = sanitizeWatchlistName(rawName); + if (!name) continue; + out.push({ name, symbols: parseSymbolList(rest).valid }); + } + return out; +} + /** File name for an exported list, e.g. "My Tech List" -> "my-tech-list.csv". */ export function watchlistExportFilename(name: string): string { const slug = name