Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions src/app/finance/watchlist-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<boolean> => {
async (text: string, verb: 'Added' | 'Imported', intoId?: string): Promise<boolean> => {
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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions src/lib/finance/watchlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
sanitizeWatchlistName,
formatSymbolsCsv,
watchlistExportFilename,
watchlistNameFromFilename,
uniqueWatchlistName,
MAX_WATCHLIST_NAME,
DEFAULT_WATCHLIST_NAME,
} from './watchlist';

describe('parseSymbolList', () => {
Expand Down Expand Up @@ -65,6 +68,45 @@
});
});

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');
Expand Down
34 changes: 34 additions & 0 deletions src/lib/finance/watchlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
Loading