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

Expand Down Expand Up @@ -63,6 +63,7 @@ export function WatchlistSection(): React.ReactElement {
const [bulk, setBulk] = useState('');
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkMsg, setBulkMsg] = useState<string | null>(null);
const importInputRef = useRef<HTMLInputElement>(null);

// List CRUD UI state.
const [newName, setNewName] = useState('');
Expand Down Expand Up @@ -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<boolean> => {
if (!text.trim()) return false;
setBulkBusy(true);
setBulkMsg(null);
try {
Expand All @@ -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(
Expand Down Expand Up @@ -328,10 +365,10 @@ export function WatchlistSection(): React.ReactElement {
</div>
</div>

{/* Active-list controls: rename / delete */}
{activeList ? (
<div className="mb-4 flex flex-wrap items-center gap-3 text-xs">
{renaming ? (
{/* Active-list controls: rename / delete / import / export */}
<div className="mb-4 flex flex-wrap items-center gap-3 text-xs">
{activeList ? (
renaming ? (
<span className="inline-flex items-center gap-1">
<input
autoFocus
Expand Down Expand Up @@ -367,9 +404,37 @@ export function WatchlistSection(): React.ReactElement {
Delete list
</button>
</>
)}
</div>
) : null}
)
) : null}

<button
type="button"
onClick={() => importInputRef.current?.click()}
disabled={bulkBusy}
className="text-text-muted hover:text-text-secondary hover:underline disabled:opacity-60"
>
Import
</button>
<button
type="button"
onClick={exportList}
disabled={watchlist.length === 0}
className="text-text-muted hover:text-text-secondary hover:underline disabled:opacity-40"
>
Export
</button>
<input
ref={importInputRef}
type="file"
accept=".csv,.txt,text/csv,text/plain"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = ''; // allow re-importing the same file
void importFile(file);
}}
/>
</div>

<form onSubmit={addBulk} className="mb-4 flex flex-col gap-2 sm:flex-row">
<input
Expand Down
38 changes: 37 additions & 1 deletion src/lib/finance/watchlist.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
import { parseSymbolList, sanitizeWatchlistName, MAX_WATCHLIST_NAME } from './watchlist';
import {
parseSymbolList,
sanitizeWatchlistName,
formatSymbolsCsv,
watchlistExportFilename,
MAX_WATCHLIST_NAME,
} from './watchlist';

describe('parseSymbolList', () => {
it('parses a comma-separated string, normalizing + de-duping', () => {
Expand Down Expand Up @@ -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');
Expand Down
23 changes: 23 additions & 0 deletions src/lib/finance/watchlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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[];
Expand Down
Loading