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
163 changes: 137 additions & 26 deletions src/app/finance/watchlist-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string | undefined> => {
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) => {
Expand All @@ -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;
Expand All @@ -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(
Expand Down Expand Up @@ -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
</button>
<button
type="button"
onClick={() => void exportAllLists()}
disabled={bulkBusy || lists.length === 0}
title="Download every list in one file"
className="text-text-muted hover:text-text-secondary hover:underline disabled:opacity-40"
>
Export all
</button>
<input
ref={importInputRef}
type="file"
Expand Down
50 changes: 50 additions & 0 deletions src/lib/finance/watchlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
parseSymbolList,
sanitizeWatchlistName,
formatSymbolsCsv,
formatWatchlistsExport,
parseWatchlistsExport,
watchlistExportFilename,
watchlistNameFromFilename,
uniqueWatchlistName,
Expand Down Expand Up @@ -68,6 +70,54 @@ describe('watchlistExportFilename', () => {
});
});

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