diff --git a/shared/stocks.json b/shared/stocks.json index d9768ab53a..92d2077cfa 100644 --- a/shared/stocks.json +++ b/shared/stocks.json @@ -1,60 +1,130 @@ { "symbols": [ - { "symbol": "^GSPC", "name": "S&P 500", "display": "SPX" }, - { "symbol": "^DJI", "name": "Dow Jones", "display": "DOW" }, - { "symbol": "^IXIC", "name": "NASDAQ", "display": "NDX" }, - { "symbol": "AAPL", "name": "Apple", "display": "AAPL" }, - { "symbol": "MSFT", "name": "Microsoft", "display": "MSFT" }, - { "symbol": "NVDA", "name": "NVIDIA", "display": "NVDA" }, - { "symbol": "GOOGL", "name": "Alphabet", "display": "GOOGL" }, - { "symbol": "AMZN", "name": "Amazon", "display": "AMZN" }, - { "symbol": "META", "name": "Meta", "display": "META" }, - { "symbol": "BRK-B", "name": "Berkshire", "display": "BRK.B" }, - { "symbol": "TSM", "name": "TSMC", "display": "TSM" }, - { "symbol": "LLY", "name": "Eli Lilly", "display": "LLY" }, - { "symbol": "TSLA", "name": "Tesla", "display": "TSLA" }, - { "symbol": "AVGO", "name": "Broadcom", "display": "AVGO" }, - { "symbol": "WMT", "name": "Walmart", "display": "WMT" }, - { "symbol": "JPM", "name": "JPMorgan", "display": "JPM" }, - { "symbol": "V", "name": "Visa", "display": "V" }, - { "symbol": "UNH", "name": "UnitedHealth", "display": "UNH" }, - { "symbol": "NVO", "name": "Novo Nordisk", "display": "NVO" }, - { "symbol": "XOM", "name": "Exxon", "display": "XOM" }, - { "symbol": "MA", "name": "Mastercard", "display": "MA" }, - { "symbol": "ORCL", "name": "Oracle", "display": "ORCL" }, - { "symbol": "PG", "name": "P&G", "display": "PG" }, - { "symbol": "COST", "name": "Costco", "display": "COST" }, - { "symbol": "JNJ", "name": "J&J", "display": "JNJ" }, - { "symbol": "HD", "name": "Home Depot", "display": "HD" }, - { "symbol": "NFLX", "name": "Netflix", "display": "NFLX" }, - { "symbol": "BAC", "name": "BofA", "display": "BAC" }, - { "symbol": "^NSEI", "name": "Nifty 50", "display": "NIFTY" }, - { "symbol": "^BSESN", "name": "BSE Sensex", "display": "SENSEX" }, - { "symbol": "RELIANCE.NS", "name": "Reliance Industries", "display": "RELIANCE" }, - { "symbol": "TCS.NS", "name": "TCS", "display": "TCS" }, - { "symbol": "HDFCBANK.NS", "name": "HDFC Bank", "display": "HDFCBANK" }, - { "symbol": "ICICIBANK.NS", "name": "ICICI Bank", "display": "ICICIBANK" }, - { "symbol": "BHARTIARTL.NS", "name": "Bharti Airtel", "display": "AIRTEL" }, - { "symbol": "INFY.NS", "name": "Infosys", "display": "INFY" }, - { "symbol": "SBIN.NS", "name": "State Bank of India", "display": "SBIN" }, - { "symbol": "LICI.NS", "name": "LIC", "display": "LICI" }, - { "symbol": "ITC.NS", "name": "ITC", "display": "ITC" }, - { "symbol": "HINDUNILVR.NS", "name": "Hindustan Unilever", "display": "HUL" }, - { "symbol": "LT.NS", "name": "L&T", "display": "LT" }, - { "symbol": "BAJFINANCE.NS", "name": "Bajaj Finance", "display": "BAJFIN" }, - { "symbol": "ADANIENT.NS", "name": "Adani Enterprises", "display": "ADANI" }, - { "symbol": "SUNPHARMA.NS", "name": "Sun Pharma", "display": "SUN" }, - { "symbol": "TITAN.NS", "name": "Titan Company", "display": "TITAN" }, - { "symbol": "M&M.NS", "name": "Mahindra & Mahindra", "display": "M&M" }, - { "symbol": "TATASTEEL.NS", "name": "Tata Steel", "display": "STEEL" }, - { "symbol": "KOTAKBANK.NS", "name": "Kotak Mahindra", "display": "KOTAK" } + { "symbol": "^GSPC", "name": "S&P 500", "display": "SPX", "region": "us" }, + { "symbol": "^DJI", "name": "Dow Jones", "display": "DOW", "region": "us" }, + { "symbol": "^IXIC", "name": "NASDAQ", "display": "NDX", "region": "us" }, + { "symbol": "AAPL", "name": "Apple", "display": "AAPL", "region": "us" }, + { "symbol": "MSFT", "name": "Microsoft", "display": "MSFT", "region": "us" }, + { "symbol": "NVDA", "name": "NVIDIA", "display": "NVDA", "region": "us" }, + { "symbol": "GOOGL", "name": "Alphabet", "display": "GOOGL", "region": "us" }, + { "symbol": "AMZN", "name": "Amazon", "display": "AMZN", "region": "us" }, + { "symbol": "META", "name": "Meta", "display": "META", "region": "us" }, + { "symbol": "BRK-B", "name": "Berkshire", "display": "BRK.B", "region": "us" }, + { "symbol": "TSM", "name": "TSMC", "display": "TSM", "region": "us" }, + { "symbol": "LLY", "name": "Eli Lilly", "display": "LLY", "region": "us" }, + { "symbol": "TSLA", "name": "Tesla", "display": "TSLA", "region": "us" }, + { "symbol": "AVGO", "name": "Broadcom", "display": "AVGO", "region": "us" }, + { "symbol": "WMT", "name": "Walmart", "display": "WMT", "region": "us" }, + { "symbol": "JPM", "name": "JPMorgan", "display": "JPM", "region": "us" }, + { "symbol": "V", "name": "Visa", "display": "V", "region": "us" }, + { "symbol": "UNH", "name": "UnitedHealth", "display": "UNH", "region": "us" }, + { "symbol": "NVO", "name": "Novo Nordisk", "display": "NVO", "region": "us" }, + { "symbol": "XOM", "name": "Exxon", "display": "XOM", "region": "us" }, + { "symbol": "MA", "name": "Mastercard", "display": "MA", "region": "us" }, + { "symbol": "ORCL", "name": "Oracle", "display": "ORCL", "region": "us" }, + { "symbol": "PG", "name": "P&G", "display": "PG", "region": "us" }, + { "symbol": "COST", "name": "Costco", "display": "COST", "region": "us" }, + { "symbol": "JNJ", "name": "J&J", "display": "JNJ", "region": "us" }, + { "symbol": "HD", "name": "Home Depot", "display": "HD", "region": "us" }, + { "symbol": "NFLX", "name": "Netflix", "display": "NFLX", "region": "us" }, + { "symbol": "BAC", "name": "BofA", "display": "BAC", "region": "us" }, + + { "symbol": "^FTSE", "name": "FTSE 100", "display": "FTSE", "region": "europe" }, + { "symbol": "^GDAXI", "name": "DAX", "display": "DAX", "region": "europe" }, + { "symbol": "^FCHI", "name": "CAC 40", "display": "CAC", "region": "europe" }, + { "symbol": "^SSMI", "name": "Swiss Market", "display": "SMI", "region": "europe" }, + { "symbol": "^AEX", "name": "AEX Amsterdam", "display": "AEX", "region": "europe" }, + { "symbol": "^IBEX", "name": "IBEX 35", "display": "IBEX", "region": "europe" }, + { "symbol": "FTSEMIB.MI", "name": "FTSE MIB", "display": "MIB", "region": "europe" }, + { "symbol": "ASML", "name": "ASML", "display": "ASML", "region": "europe" }, + { "symbol": "SAP", "name": "SAP", "display": "SAP", "region": "europe" }, + { "symbol": "NESN.SW", "name": "Nestle", "display": "NESN", "region": "europe" }, + { "symbol": "AZN", "name": "AstraZeneca", "display": "AZN", "region": "europe" }, + { "symbol": "SHEL", "name": "Shell", "display": "SHEL", "region": "europe" }, + + { "symbol": "^N225", "name": "Nikkei 225", "display": "N225", "region": "asia" }, + { "symbol": "^HSI", "name": "Hang Seng", "display": "HSI", "region": "asia" }, + { "symbol": "000001.SS", "name": "SSE Composite", "display": "SSE", "region": "asia" }, + { "symbol": "^KS11", "name": "KOSPI", "display": "KOSPI", "region": "asia" }, + { "symbol": "^TWII", "name": "TAIEX", "display": "TAIEX", "region": "asia" }, + { "symbol": "^AXJO", "name": "ASX 200", "display": "ASX", "region": "asia" }, + { "symbol": "^STI", "name": "Straits Times", "display": "STI", "region": "asia" }, + { "symbol": "^JKSE", "name": "Jakarta Composite", "display": "JKSE", "region": "asia" }, + { "symbol": "9984.T", "name": "SoftBank", "display": "SFTBY", "region": "asia" }, + { "symbol": "7203.T", "name": "Toyota", "display": "TM", "region": "asia" }, + { "symbol": "005930.KS", "name": "Samsung", "display": "SMSN", "region": "asia" }, + { "symbol": "9988.HK", "name": "Alibaba", "display": "BABA", "region": "asia" }, + { "symbol": "0700.HK", "name": "Tencent", "display": "TCEHY", "region": "asia" }, + + { "symbol": "^NSEI", "name": "Nifty 50", "display": "NIFTY", "region": "india" }, + { "symbol": "^BSESN", "name": "BSE Sensex", "display": "SENSEX", "region": "india" }, + { "symbol": "RELIANCE.NS", "name": "Reliance Industries", "display": "RELIANCE", "region": "india" }, + { "symbol": "TCS.NS", "name": "TCS", "display": "TCS", "region": "india" }, + { "symbol": "HDFCBANK.NS", "name": "HDFC Bank", "display": "HDFCBANK", "region": "india" }, + { "symbol": "ICICIBANK.NS", "name": "ICICI Bank", "display": "ICICIBANK", "region": "india" }, + { "symbol": "BHARTIARTL.NS", "name": "Bharti Airtel", "display": "AIRTEL", "region": "india" }, + { "symbol": "INFY.NS", "name": "Infosys", "display": "INFY", "region": "india" }, + { "symbol": "SBIN.NS", "name": "State Bank of India", "display": "SBIN", "region": "india" }, + { "symbol": "LICI.NS", "name": "LIC", "display": "LICI", "region": "india" }, + { "symbol": "ITC.NS", "name": "ITC", "display": "ITC", "region": "india" }, + { "symbol": "HINDUNILVR.NS", "name": "Hindustan Unilever", "display": "HUL", "region": "india" }, + { "symbol": "LT.NS", "name": "L&T", "display": "LT", "region": "india" }, + { "symbol": "BAJFINANCE.NS", "name": "Bajaj Finance", "display": "BAJFIN", "region": "india" }, + { "symbol": "ADANIENT.NS", "name": "Adani Enterprises", "display": "ADANI", "region": "india" }, + { "symbol": "SUNPHARMA.NS", "name": "Sun Pharma", "display": "SUN", "region": "india" }, + { "symbol": "TITAN.NS", "name": "Titan Company", "display": "TITAN", "region": "india" }, + { "symbol": "M&M.NS", "name": "Mahindra & Mahindra", "display": "M&M", "region": "india" }, + { "symbol": "TATASTEEL.NS", "name": "Tata Steel", "display": "STEEL", "region": "india" }, + { "symbol": "KOTAKBANK.NS", "name": "Kotak Mahindra", "display": "KOTAK", "region": "india" }, + + { "symbol": "^TASI.SR", "name": "Tadawul All Share", "display": "TASI", "region": "gcc" }, + { "symbol": "DFMGI.AE", "name": "Dubai Financial Market", "display": "DFM", "region": "gcc" }, + { "symbol": "QAT", "name": "Qatar (iShares)", "display": "QAT", "region": "gcc" }, + { "symbol": "UAE", "name": "Abu Dhabi (iShares)", "display": "UAE", "region": "gcc" }, + { "symbol": "2222.SR", "name": "Saudi Aramco", "display": "ARAMCO", "region": "gcc" }, + { "symbol": "2010.SR", "name": "SABIC", "display": "SABIC", "region": "gcc" }, + + { "symbol": "^BVSP", "name": "Bovespa", "display": "BVSP", "region": "americas" }, + { "symbol": "^GSPTSE", "name": "TSX Composite", "display": "TSX", "region": "americas" }, + { "symbol": "^MXX", "name": "IPC Mexico", "display": "IPC", "region": "americas" }, + { "symbol": "^MERV", "name": "MERVAL", "display": "MERV", "region": "americas" }, + { "symbol": "SHOP.TO", "name": "Shopify", "display": "SHOP", "region": "americas" }, + { "symbol": "NU", "name": "Nu Holdings", "display": "NU", "region": "americas" } ], "yahooOnly": [ "^GSPC", "^DJI", "^IXIC", + "^FTSE", "^GDAXI", "^FCHI", "^SSMI", "^AEX", "^IBEX", "FTSEMIB.MI", + "^N225", "^HSI", "000001.SS", "^KS11", "^TWII", "^AXJO", "^STI", "^JKSE", + "9984.T", "7203.T", "005930.KS", "9988.HK", "0700.HK", "^NSEI", "^BSESN", "RELIANCE.NS", "TCS.NS", "HDFCBANK.NS", "ICICIBANK.NS", "BHARTIARTL.NS", "INFY.NS", "SBIN.NS", "LICI.NS", "ITC.NS", "HINDUNILVR.NS", "LT.NS", "BAJFINANCE.NS", "ADANIENT.NS", "SUNPHARMA.NS", "TITAN.NS", - "M&M.NS", "TATASTEEL.NS", "KOTAKBANK.NS" + "M&M.NS", "TATASTEEL.NS", "KOTAKBANK.NS", + "^TASI.SR", "DFMGI.AE", "2222.SR", "2010.SR", + "^BVSP", "^GSPTSE", "^MXX", "^MERV", "SHOP.TO", + "NESN.SW" + ], + "regions": { + "us": "US Markets", + "europe": "Europe", + "asia": "Asia-Pacific", + "india": "India", + "gcc": "GCC / Middle East", + "americas": "Americas (ex-US)" + }, + "defaultSymbols": [ + "^GSPC", "^DJI", "^IXIC", + "AAPL", "MSFT", "NVDA", "GOOGL", "AMZN", "META", + "BRK-B", "TSM", "LLY", "TSLA", "AVGO", + "WMT", "JPM", "V", "UNH", "NVO", + "XOM", "MA", "ORCL", "PG", "COST", + "JNJ", "HD", "NFLX", "BAC", + "^NSEI", "^BSESN", + "RELIANCE.NS", "TCS.NS", "HDFCBANK.NS", "ICICIBANK.NS", + "BHARTIARTL.NS", "INFY.NS", "SBIN.NS", "LICI.NS", + "ITC.NS", "HINDUNILVR.NS", "LT.NS", "BAJFINANCE.NS", + "ADANIENT.NS", "SUNPHARMA.NS", "TITAN.NS", "M&M.NS", + "TATASTEEL.NS", "KOTAKBANK.NS" ] } diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index f6d5e2f61c..31ffb51c07 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -10,6 +10,7 @@ import { SECTORS, COMMODITIES, MARKET_SYMBOLS, + STOCK_CATALOG, SITE_VARIANT, LAYER_TO_SOURCE, DEFAULT_PANELS, @@ -63,7 +64,7 @@ import { fetchSanctionsPressure, fetchRadiationWatch, } from '@/services'; -import { getMarketWatchlistEntries } from '@/services/market-watchlist'; +import { getMarketWatchlistEntries, getCatalogSelection } from '@/services/market-watchlist'; import { fetchStockAnalysesForTargets, getStockAnalysisTargets } from '@/services/stock-analysis'; import { fetchStockBacktestsForTargets, @@ -1171,17 +1172,29 @@ export class DataLoaderManager implements AppModule { async loadMarkets(): Promise { try { + const catalogSelection = getCatalogSelection(); const customEntries = getMarketWatchlistEntries(); const effectiveSymbols = (() => { - if (customEntries.length === 0) return MARKET_SYMBOLS; - const base = MARKET_SYMBOLS.slice(); + // If user picked from the catalog, use that as the base instead of defaults + let base; + if (catalogSelection && catalogSelection.length > 0) { + const catalogMap = new Map(STOCK_CATALOG.map((s) => [s.symbol, s])); + base = catalogSelection + .map((sym) => catalogMap.get(sym)) + .filter((s): s is NonNullable => !!s); + if (base.length === 0) base = MARKET_SYMBOLS.slice(); + } else { + base = MARKET_SYMBOLS.slice(); + } + + // Append any freeform custom entries on top const seen = new Set(base.map((s) => s.symbol)); for (const entry of customEntries) { const sym = entry.symbol; if (!sym || seen.has(sym)) continue; seen.add(sym); base.push({ symbol: sym, name: entry.name || sym, display: entry.display || sym }); - if (base.length >= 50) break; + if (base.length >= 80) break; } return base; })(); diff --git a/src/components/MarketPanel.ts b/src/components/MarketPanel.ts index a1d00d4763..1014a157c1 100644 --- a/src/components/MarketPanel.ts +++ b/src/components/MarketPanel.ts @@ -5,106 +5,45 @@ import { formatPrice, formatChange, getChangeClass, getHeatmapClass } from '@/ut import { escapeHtml } from '@/utils/sanitize'; import { miniSparkline } from '@/utils/sparkline'; import { - getMarketWatchlistEntries, - parseMarketWatchlistInput, - resetMarketWatchlist, - setMarketWatchlistEntries, + STOCK_CATALOG, + REGION_LABELS, + MARKET_SYMBOLS, + type CatalogSymbol, +} from '@/config/markets'; +import { + getCatalogSelection, + setCatalogSelection, + clearCatalogSelection, } from '@/services/market-watchlist'; export class MarketPanel extends Panel { - private settingsBtn: HTMLButtonElement | null = null; - private overlay: HTMLElement | null = null; + private pickerOverlay: HTMLElement | null = null; + private escHandler: ((e: KeyboardEvent) => void) | null = null; constructor() { super({ id: 'markets', title: t('panels.markets'), infoTooltip: t('components.markets.infoTooltip') }); - this.createSettingsButton(); - } - - private createSettingsButton(): void { - this.settingsBtn = document.createElement('button'); - this.settingsBtn.className = 'live-news-settings-btn'; - this.settingsBtn.title = 'Customize market watchlist'; - this.settingsBtn.textContent = 'Watchlist'; - this.settingsBtn.addEventListener('click', (e) => { - e.stopPropagation(); - this.openWatchlistModal(); - }); - this.header.appendChild(this.settingsBtn); + this.addEditButton(); } - private openWatchlistModal(): void { - if (this.overlay) return; - - const current = getMarketWatchlistEntries(); - const currentText = current.length - ? current.map((e) => (e.name ? `${e.symbol}|${e.name}` : e.symbol)).join('\n') - : ''; - - const overlay = document.createElement('div'); - overlay.className = 'modal-overlay active'; - overlay.id = 'marketWatchlistModal'; - overlay.addEventListener('click', (e) => { - if (e.target === overlay) this.closeWatchlistModal(); - }); - - const modal = document.createElement('div'); - modal.className = 'modal unified-settings-modal'; - modal.style.maxWidth = '680px'; + private addEditButton(): void { + const btn = document.createElement('button'); + btn.className = 'icon-btn market-edit-btn'; + btn.title = 'Customize watchlist'; + btn.innerHTML = ``; - modal.innerHTML = ` - -
-
- Add extra tickers (comma or newline separated). Friendly labels supported: SYMBOL|Label. - Example: TSLA|Tesla, AAPL|Apple, ^GSPC|S&P 500 -
- Tip: keep it under ~30 unless you enjoy scrolling. -
- -
- - - -
-
- `; - - const closeBtn = modal.querySelector('.modal-close') as HTMLButtonElement | null; - closeBtn?.addEventListener('click', () => this.closeWatchlistModal()); - - overlay.appendChild(modal); - document.body.appendChild(overlay); - this.overlay = overlay; - - const input = modal.querySelector('#wmMarketWatchlistInput'); - if (input) input.value = currentText; + const closeBtn = this.header.querySelector('.panel-close-btn'); + if (closeBtn) { + this.header.insertBefore(btn, closeBtn); + } else { + this.header.appendChild(btn); + } - modal.querySelector('#wmMarketCancelBtn')?.addEventListener('click', () => this.closeWatchlistModal()); - modal.querySelector('#wmMarketResetBtn')?.addEventListener('click', () => { - resetMarketWatchlist(); - if (input) input.value = ''; // defaults are always included automatically - this.closeWatchlistModal(); - }); - modal.querySelector('#wmMarketSaveBtn')?.addEventListener('click', () => { - const raw = input?.value || ''; - const parsed = parseMarketWatchlistInput(raw); - if (parsed.length === 0) resetMarketWatchlist(); - else setMarketWatchlistEntries(parsed); - this.closeWatchlistModal(); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.openPicker(); }); } - private closeWatchlistModal(): void { - if (!this.overlay) return; - this.overlay.remove(); - this.overlay = null; - } - public renderMarkets(data: MarketData[], rateLimited?: boolean): void { if (data.length === 0) { this.showRetrying(rateLimited ? t('common.rateLimitedMarket') : t('common.failedMarketData')); @@ -131,6 +70,173 @@ export class MarketPanel extends Panel { this.setContent(html); } + + private openPicker(): void { + if (this.pickerOverlay) return; + + const saved = getCatalogSelection(); + const defaultSyms = MARKET_SYMBOLS.map((s) => s.symbol); + const selected = new Set(saved || defaultSyms); + + let activeRegion = 'all'; + let filterText = ''; + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay active'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-label', 'Customize Markets'); + this.pickerOverlay = overlay; + + const regionKeys = Object.keys(REGION_LABELS); + + const getVisible = (): CatalogSymbol[] => { + let list: CatalogSymbol[] = STOCK_CATALOG; + if (activeRegion !== 'all') { + list = list.filter((s) => s.region === activeRegion); + } + if (filterText) { + const q = filterText.toLowerCase(); + list = list.filter( + (s) => + s.name.toLowerCase().includes(q) || + s.symbol.toLowerCase().includes(q) || + s.display.toLowerCase().includes(q), + ); + } + return list; + }; + + const renderPills = () => { + const bar = overlay.querySelector('.wl-region-bar'); + if (!bar) return; + let html = ``; + for (const key of regionKeys) { + html += ``; + } + bar.innerHTML = html; + }; + + const renderGrid = () => { + const grid = overlay.querySelector('.wl-grid'); + if (!grid) return; + const visible = getVisible(); + grid.innerHTML = visible + .map((s) => { + const on = selected.has(s.symbol); + return `
+
${on ? '✓' : ''}
+
+ ${escapeHtml(s.name)} + ${escapeHtml(s.display)} +
+
`; + }) + .join(''); + updateCounter(); + }; + + const updateCounter = () => { + const el = overlay.querySelector('.wl-counter'); + if (el) el.textContent = `${selected.size} selected`; + }; + + overlay.innerHTML = ` + + `; + + overlay.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + + if (target === overlay || target.closest('.wl-close')) { + this.closePicker(); + return; + } + + const pill = target.closest('.wl-pill'); + if (pill?.dataset.region) { + activeRegion = pill.dataset.region; + renderPills(); + renderGrid(); + return; + } + + const item = target.closest('.wl-item'); + if (item?.dataset.symbol) { + const sym = item.dataset.symbol; + if (selected.has(sym)) selected.delete(sym); + else selected.add(sym); + item.classList.toggle('active'); + const check = item.querySelector('.wl-check'); + if (check) check.innerHTML = selected.has(sym) ? '✓' : ''; + updateCounter(); + return; + } + + if (target.closest('.wl-reset')) { + clearCatalogSelection(); + this.closePicker(); + return; + } + + if (target.closest('.wl-save')) { + const ordered = STOCK_CATALOG + .filter((s) => selected.has(s.symbol)) + .map((s) => s.symbol); + if (ordered.length > 0) { + setCatalogSelection(ordered); + } else { + clearCatalogSelection(); + } + this.closePicker(); + return; + } + }); + + overlay.addEventListener('input', (e) => { + const input = e.target as HTMLInputElement; + if (input.closest('.wl-search')) { + filterText = input.value; + renderGrid(); + } + }); + + this.escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') this.closePicker(); + }; + document.addEventListener('keydown', this.escHandler); + + document.body.appendChild(overlay); + renderPills(); + renderGrid(); + } + + private closePicker(): void { + if (this.pickerOverlay) { + this.pickerOverlay.remove(); + this.pickerOverlay = null; + } + if (this.escHandler) { + document.removeEventListener('keydown', this.escHandler); + this.escHandler = null; + } + } } export class HeatmapPanel extends Panel { diff --git a/src/config/index.ts b/src/config/index.ts index a66354ac89..1188df2d49 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -15,7 +15,7 @@ export { } from './variants/base'; // Market data (shared) -export { SECTORS, COMMODITIES, MARKET_SYMBOLS, CRYPTO_MAP } from './markets'; +export { SECTORS, COMMODITIES, MARKET_SYMBOLS, CRYPTO_MAP, STOCK_CATALOG } from './markets'; // Geo data (shared base) export { UNDERSEA_CABLES, MAP_URLS } from './geo'; diff --git a/src/config/markets.ts b/src/config/markets.ts index 4a3aea510d..ae43b674d7 100644 --- a/src/config/markets.ts +++ b/src/config/markets.ts @@ -8,7 +8,19 @@ export const SECTORS: Sector[] = sectorConfig.sectors as Sector[]; export const COMMODITIES: Commodity[] = commodityConfig.commodities as Commodity[]; -export const MARKET_SYMBOLS: MarketSymbol[] = stocksConfig.symbols as MarketSymbol[]; +export interface CatalogSymbol extends MarketSymbol { + region: string; +} + +export const STOCK_CATALOG: CatalogSymbol[] = stocksConfig.symbols as CatalogSymbol[]; + +export const REGION_LABELS: Record = stocksConfig.regions; + +const DEFAULT_SYMBOL_SET = new Set(stocksConfig.defaultSymbols); + +export const MARKET_SYMBOLS: MarketSymbol[] = STOCK_CATALOG.filter( + (s) => DEFAULT_SYMBOL_SET.has(s.symbol), +); export const CRYPTO_IDS = cryptoConfig.ids as readonly string[]; export const CRYPTO_MAP: Record = cryptoConfig.meta; diff --git a/src/services/market-watchlist.ts b/src/services/market-watchlist.ts index 22abc522da..855bc167fa 100644 --- a/src/services/market-watchlist.ts +++ b/src/services/market-watchlist.ts @@ -1,8 +1,12 @@ /** - * User-customizable market watchlist (additive). + * User-customizable market watchlist. * - * Stores a list of extra tickers the user wants to track beyond the defaults. - * Optional friendly label is supported (used as the displayed name). + * Two layers: + * 1. Catalog selection — user picks symbols from the built-in catalog (full replacement). + * 2. Custom entries — freeform tickers typed manually (appended on top). + * + * When a catalog selection exists the defaults are replaced entirely. + * Custom entries are always additive on top of whichever base list is active. */ export interface MarketWatchlistEntry { @@ -14,6 +18,7 @@ export interface MarketWatchlistEntry { } const STORAGE_KEY = 'wm-market-watchlist-v1'; +const CATALOG_KEY = 'wm-market-catalog-selection-v1'; export const MARKET_WATCHLIST_EVENT = 'wm-market-watchlist-changed'; function safeParseJson(raw: string | null): T | null { @@ -135,3 +140,29 @@ export function parseMarketWatchlistInput(text: string): MarketWatchlistEntry[] return entries; } + +// ---- Catalog selection (visual picker) ---- + +export function getCatalogSelection(): string[] | null { + try { + const raw = localStorage.getItem(CATALOG_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) return parsed as string[]; + return null; + } catch { + return null; + } +} + +export function setCatalogSelection(symbols: string[]): void { + try { + localStorage.setItem(CATALOG_KEY, JSON.stringify(symbols)); + } catch { /* ignore */ } + window.dispatchEvent(new CustomEvent(MARKET_WATCHLIST_EVENT, { detail: { catalogChanged: true } })); +} + +export function clearCatalogSelection(): void { + try { localStorage.removeItem(CATALOG_KEY); } catch { /* ignore */ } + window.dispatchEvent(new CustomEvent(MARKET_WATCHLIST_EVENT, { detail: { catalogChanged: true } })); +} diff --git a/src/styles/main.css b/src/styles/main.css index c9c75e8864..ed98fadf0f 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -5846,6 +5846,194 @@ body.playback-mode .status-dot { color: var(--red); } +/* Market edit button */ +.market-edit-btn { + opacity: 0; + transition: opacity 0.15s ease; +} + +.panel:hover .market-edit-btn { + opacity: 1; +} + +@media (hover: none) { + .market-edit-btn { + opacity: 0.7; + } +} + +/* Watchlist picker modal */ +.wl-modal { + max-width: 520px; + width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.wl-region-bar { + display: flex; + gap: 4px; + padding: 0 16px 8px; + flex-wrap: wrap; +} + +.wl-pill { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 12px; + background: transparent; + color: var(--text-dim); + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; +} + +.wl-pill:hover { + border-color: var(--text-dim); + color: var(--text); +} + +.wl-pill.active { + background: var(--accent); + border-color: var(--accent); + color: #000; +} + +.wl-search { + padding: 0 16px 8px; +} + +.wl-search input { + width: 100%; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: var(--text); + font-size: 12px; + outline: none; +} + +.wl-search input:focus { + border-color: var(--accent); +} + +.wl-grid { + flex: 1; + overflow-y: auto; + padding: 0 16px; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 6px; + align-content: start; + min-height: 200px; + max-height: 45vh; +} + +.wl-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: all 0.12s ease; + user-select: none; +} + +.wl-item:hover { + border-color: var(--text-dim); +} + +.wl-item.active { + border-color: var(--accent); + background: rgba(0, 200, 255, 0.06); +} + +.wl-check { + width: 16px; + height: 16px; + border: 1px solid var(--border); + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--accent); + flex-shrink: 0; +} + +.wl-item.active .wl-check { + border-color: var(--accent); + background: rgba(0, 200, 255, 0.15); +} + +.wl-item-info { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; +} + +.wl-item-name { + font-size: 11px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wl-item-ticker { + font-size: 9px; + color: var(--text-dim); +} + +.wl-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-top: 1px solid var(--border); +} + +.wl-counter { + font-size: 11px; + color: var(--text-dim); +} + +.wl-actions { + display: flex; + gap: 8px; +} + +.wl-btn { + padding: 5px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: transparent; + color: var(--text-dim); + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; +} + +.wl-btn:hover { + border-color: var(--text-dim); + color: var(--text); +} + +.wl-btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #000; +} + +.wl-btn-primary:hover { + opacity: 0.9; +} + /* Gulf Economies */ .gulf-section { margin-bottom: 8px;