From 000bbce7049abba1a71a4c6da220e053b7fa03e8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 18:38:05 +0000 Subject: [PATCH] feat(finance): import a watchlist file into a new list named after it Import now creates its own list instead of merging into whatever tab was active: "my-tech-list.csv" -> "My Tech List", so an exported list round-trips. A name already in use gets a numeric suffix ("Tech 2") rather than a second identical tab, and the file's symbols are validated client-side first so a junk file never leaves an empty list behind. If list creation fails, the import falls back to the active list. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/finance/watchlist-section.tsx | 59 ++++++++++++++++++++++----- src/lib/finance/watchlist.test.ts | 42 +++++++++++++++++++ src/lib/finance/watchlist.ts | 34 +++++++++++++++ 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/app/finance/watchlist-section.tsx b/src/app/finance/watchlist-section.tsx index 2f3da50..ad69555 100644 --- a/src/app/finance/watchlist-section.tsx +++ b/src/app/finance/watchlist-section.tsx @@ -14,7 +14,14 @@ 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, formatSymbolsCsv, watchlistExportFilename } from '@/lib/finance/watchlist'; +import { + MAX_WATCHLIST_NAME, + formatSymbolsCsv, + parseSymbolList, + uniqueWatchlistName, + watchlistExportFilename, + watchlistNameFromFilename, +} from '@/lib/finance/watchlist'; import type { WatchlistChanges } from '@/lib/finance/performance'; import type { Quote } from '@/lib/finance/market-data/types'; @@ -213,19 +220,22 @@ export function WatchlistSection(): React.ReactElement { }, [activeId, activeList, loadLists]); // --- Add tickers ---------------------------------------------------------- - /** Send a pasted / imported ticker blob to the bulk-add endpoint. */ + /** + * Send a pasted / imported ticker blob to the bulk-add endpoint. `intoId` + * overrides the target list (import creates its own); when it and `activeId` + * are both null the server creates the profile's default list and returns it. + */ const submitSymbols = useCallback( - async (text: string, verb: 'Added' | 'Imported'): Promise => { + async (text: string, verb: 'Added' | 'Imported', intoId?: string): Promise => { if (!text.trim()) return false; + const listId = intoId ?? activeId; setBulkBusy(true); setBulkMsg(null); try { const res = await fetch('/api/finance/watchlist', { method: 'POST', 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: text, watchlistId: activeId } : { symbols: text }), + body: JSON.stringify(listId ? { symbols: text, watchlistId: listId } : { symbols: text }), }); const body = await res.json().catch(() => ({})); if (!res.ok) { @@ -239,7 +249,7 @@ export function WatchlistSection(): React.ReactElement { (invalid.length ? ` ยท skipped ${invalid.length} invalid (${invalid.slice(0, 5).join(', ')})` : ''), ); const next = await loadLists(); - const targetId = (body.watchlistId as string) ?? activeId ?? next[0]?.id ?? null; + const targetId = (body.watchlistId as string) ?? listId ?? next[0]?.id ?? null; setActiveId(targetId); if (targetId === activeId) loadItems(); return true; @@ -274,7 +284,12 @@ export function WatchlistSection(): React.ReactElement { URL.revokeObjectURL(url); }, [watchlist, activeList]); - /** Read a comma-separated ticker file and add its symbols to the active list. */ + /** + * 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. + */ const importFile = useCallback( async (file: File | undefined) => { if (!file) return; @@ -283,9 +298,32 @@ export function WatchlistSection(): React.ReactElement { setBulkMsg('That file was empty.'); return; } - await submitSymbols(text, 'Imported'); + if (parseSymbolList(text).valid.length === 0) { + setBulkMsg('No valid tickers found.'); + return; + } + + const name = uniqueWatchlistName( + 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 + } + await submitSymbols(text, 'Imported', newId); }, - [submitSymbols], + [lists, submitSymbols], ); const removeSymbol = useCallback( @@ -411,6 +449,7 @@ export function WatchlistSection(): React.ReactElement { type="button" onClick={() => importInputRef.current?.click()} disabled={bulkBusy} + title="Import a ticker file into a new list named after the file" className="text-text-muted hover:text-text-secondary hover:underline disabled:opacity-60" > Import diff --git a/src/lib/finance/watchlist.test.ts b/src/lib/finance/watchlist.test.ts index c0da47c..16565b2 100644 --- a/src/lib/finance/watchlist.test.ts +++ b/src/lib/finance/watchlist.test.ts @@ -4,7 +4,10 @@ import { sanitizeWatchlistName, formatSymbolsCsv, watchlistExportFilename, + watchlistNameFromFilename, + uniqueWatchlistName, MAX_WATCHLIST_NAME, + DEFAULT_WATCHLIST_NAME, } from './watchlist'; describe('parseSymbolList', () => { @@ -65,6 +68,45 @@ describe('watchlistExportFilename', () => { }); }); +describe('watchlistNameFromFilename', () => { + it('round-trips an exported file name back to the list name', () => { + const file = watchlistExportFilename('My Tech List'); + expect(watchlistNameFromFilename(file)).toBe('My Tech List'); + }); + + it('keeps the user\'s own capitalization', () => { + expect(watchlistNameFromFilename('FAANG picks.txt')).toBe('FAANG picks'); + }); + + it('strips directories, extensions and separators', () => { + expect(watchlistNameFromFilename('/tmp/dir/high_beta-names.csv')).toBe('High Beta Names'); + }); + + it('falls back for a nameless file', () => { + expect(watchlistNameFromFilename('.csv')).toBe(DEFAULT_WATCHLIST_NAME); + }); + + it('caps the length', () => { + expect(watchlistNameFromFilename(`${'x'.repeat(200)}.csv`)).toHaveLength(MAX_WATCHLIST_NAME); + }); +}); + +describe('uniqueWatchlistName', () => { + it('passes through when there is no collision', () => { + expect(uniqueWatchlistName('Tech', ['Energy'])).toBe('Tech'); + }); + + it('suffixes past existing names, case-insensitively', () => { + expect(uniqueWatchlistName('Tech', ['tech'])).toBe('Tech 2'); + expect(uniqueWatchlistName('Tech', ['Tech', 'Tech 2'])).toBe('Tech 3'); + }); + + it('keeps the suffixed name within the length cap', () => { + const long = 'x'.repeat(MAX_WATCHLIST_NAME); + expect(uniqueWatchlistName(long, [long]).length).toBeLessThanOrEqual(MAX_WATCHLIST_NAME); + }); +}); + 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 d3a8a88..d96b553 100644 --- a/src/lib/finance/watchlist.ts +++ b/src/lib/finance/watchlist.ts @@ -45,6 +45,40 @@ export function watchlistExportFilename(name: string): string { return `${slug || 'watchlist'}.csv`; } +/** + * Derive a list name from an imported file name โ€” the inverse of + * {@link watchlistExportFilename}, so an exported list round-trips back to a + * recognizable name. Separators become spaces; an all-lowercase result (what + * our own export produces) is title-cased, while a name the user capitalized + * themselves is left alone. + */ +export function watchlistNameFromFilename(filename: string): string { + const base = filename + .replace(/\.[^./\\]*$/, '') // drop the extension + .replace(/^.*[/\\]/, '') // drop any directory prefix + .replace(/[-_]+/g, ' ') + .trim() + .replace(/\s+/g, ' '); + if (!base) return DEFAULT_WATCHLIST_NAME; + const named = base === base.toLowerCase() ? base.replace(/\b[a-z]/g, (c) => c.toUpperCase()) : base; + return named.slice(0, MAX_WATCHLIST_NAME); +} + +/** + * Suffix a name until it no longer collides with `existing`, so importing the + * same file twice yields "Tech 2" rather than a second identical tab. + */ +export function uniqueWatchlistName(name: string, existing: string[]): string { + const taken = new Set(existing.map((n) => n.trim().toLowerCase())); + if (!taken.has(name.toLowerCase())) return name; + for (let n = 2; n < 1000; n += 1) { + const suffix = ` ${n}`; + const candidate = `${name.slice(0, MAX_WATCHLIST_NAME - suffix.length).trimEnd()}${suffix}`; + if (!taken.has(candidate.toLowerCase())) return candidate; + } + return name; +} + export interface ParsedSymbolList { /** Valid, normalized, de-duplicated symbols. */ valid: string[];