diff --git a/src/lib/components/Searchbar.svelte b/src/lib/components/Searchbar.svelte index 03868ce2..b49594f2 100644 --- a/src/lib/components/Searchbar.svelte +++ b/src/lib/components/Searchbar.svelte @@ -19,6 +19,7 @@ let searchOpen = false; let searchBarModalChecked = false; // Initialize it to false let inputElement; + let isNavigating = false; const popularList = [ { @@ -49,12 +50,14 @@ ]; async function handleSearch(symbol, assetType) { + if (isNavigating) return; // Prevent double calls + isNavigating = true; + // Find the matching ticker data const newSearchItem = searchBarData?.find( (item) => item?.symbol === symbol?.toUpperCase(), ); if (newSearchItem) { - // Ensure `upperState` matches the case of `item.symbol` updatedSearchHistory = [ newSearchItem, ...(searchHistory?.filter( @@ -63,28 +66,39 @@ ].slice(0, 5); } - searchBarTicker(symbol); + // Close search modal + searchOpen = false; + if ($screenWidth < 640) { + const closePopup = document.getElementById("searchBarModal"); + closePopup?.dispatchEvent(new MouseEvent("click")); + } - nextPage = true; - goto( + await goto( `/${assetType === "ETF" ? "etf" : assetType === "Index" ? "index" : "stocks"}/${symbol}`, ); + + // Reset the flag after navigation + setTimeout(() => { + isNavigating = false; + }, 100); } async function popularTicker(state) { + if (isNavigating) return; + isNavigating = true; + searchOpen = false; - if (!state) return; + if (!state) { + isNavigating = false; + return; + } - // Convert state to uppercase once const upperState = state.toUpperCase(); - - // Find the matching ticker data const newSearchItem = searchBarData?.find( ({ symbol }) => symbol === upperState, ); if (newSearchItem) { - // Ensure `upperState` matches the case of `item.symbol` updatedSearchHistory = [ newSearchItem, ...(searchHistory?.filter( @@ -93,14 +107,19 @@ ].slice(0, 5); } - // Close search modal const closePopup = document.getElementById("searchBarModal"); closePopup?.dispatchEvent(new MouseEvent("click")); + + // Reset the flag after a short delay + setTimeout(() => { + isNavigating = false; + }, 100); } function searchBarTicker(state) { + if (isNavigating) return; showSuggestions = false; - // Early return if state is empty or ticker not found + if ( !state || !searchBarData?.find((item) => item?.symbol === state?.toUpperCase()) @@ -109,29 +128,23 @@ const closePopup = document.getElementById("searchBarModal"); closePopup?.dispatchEvent(new MouseEvent("click")); } - return; } - // Convert state to uppercase once const upperState = state.toUpperCase(); - - // Find the matching ticker data const newSearchItem = searchBarData?.find( ({ symbol }) => symbol === upperState, ); if (newSearchItem) { - // Ensure `upperState` matches the case of `item.symbol` updatedSearchHistory = [ newSearchItem, ...(searchHistory?.filter( (item) => item?.symbol?.toUpperCase() !== upperState, ) || []), - ]?.slice(0, 5); + ].slice(0, 5); } - // Close search modal searchOpen = false; if ($screenWidth < 640) { const closePopup = document.getElementById("searchBarModal"); @@ -160,6 +173,8 @@ } function handleKeyDown(symbol) { + if (isNavigating) return; + const list = Array.from( new Map( [...searchHistory, ...searchBarData, ...popularList].map((item) => [ @@ -172,8 +187,11 @@ if (!list?.length) return; const newData = list.find((item) => item?.symbol === symbol); - handleSearch(newData?.symbol, newData?.type); + if (newData) { + handleSearch(newData?.symbol, newData?.type); + } } + const handleControlK = async (event) => { if (event.ctrlKey && event.key === "k") { const keyboardSearch = document.getElementById("combobox-input"); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 052ef740..0be97bd4 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -428,7 +428,7 @@ {item?.ticker} @@ -441,7 +441,7 @@ {item?.ticker} @@ -489,14 +489,16 @@
According to {analystReport?.numOfAnalyst} analyst ratings, the average rating for - {analystReport?.symbol} stock is "{analystReport?.consensusRating}" The 12-month stock price forecast is ${analystReport?.highPriceTarget}, which is an {analystReport?.highPriceChange > 0 ? "increase" - : "decreas"} of {analystReport?.highPriceChange}% from the + : "decrease"} of {analystReport?.highPriceChange}% from the latest price.
{ - const substringsToRemove = [ +// Pre-compile regex pattern and substrings for cleaning +const REMOVE_PATTERNS = { + pattern: new RegExp(`\\b(${[ "Depositary", "Inc.", "Incorporated", @@ -11,81 +12,126 @@ const cleanString = (input) => { "Oyj", "Company", "The", - "plc", - ]; - const pattern = new RegExp(`\\b(${substringsToRemove.join("|")})\\b|,`, "gi"); - return input?.replace(pattern, "").trim(); + "plc" + ].join("|")})\\b|,`, "gi") }; -const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds -const REQUEST_TIMEOUT = 5000; // 5 seconds -const cache = new Map(); +// Memoized string cleaning function +const cleanString = (() => { + const cache = new Map(); + return (input) => { + if (!input) return ''; + if (cache.has(input)) return cache.get(input); + const cleaned = input.replace(REMOVE_PATTERNS.pattern, '').trim(); + cache.set(input, cleaned); + return cleaned; + }; +})(); -const fetchData = async (apiURL, apiKey, endpoint, ticker) => { - const cacheKey = `${endpoint}-${ticker}`; - const cached = cache.get(cacheKey); - - if (cached && Date.now() - cached.timestamp < CACHE_DURATION) { - return cached.data; +// Constants +const CACHE_DURATION = 5 * 60 * 1000; +const REQUEST_TIMEOUT = 5000; +const ENDPOINTS = Object.freeze([ + "/stockdeck", + "/analyst-summary-rating", + "/stock-quote", + "/pre-post-quote", + "/wiim", + "/one-day-price", + "/next-earnings", + "/earnings-surprise", + "/stock-news" +]); + +// LRU Cache implementation with automatic cleanup +class LRUCache { + constructor(maxSize = 100) { + this.cache = new Map(); + this.maxSize = maxSize; } + get(key) { + const item = this.cache.get(key); + if (!item) return null; + if (Date.now() - item.timestamp >= CACHE_DURATION) { + this.cache.delete(key); + return null; + } + return item.data; + } + + set(key, data) { + if (this.cache.size >= this.maxSize) { + const oldestKey = this.cache.keys().next().value; + this.cache.delete(oldestKey); + } + this.cache.set(key, { data, timestamp: Date.now() }); + } +} + +const dataCache = new LRUCache(); + +// Optimized fetch function with AbortController and timeout +const fetchWithTimeout = async (url, options, timeout) => { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); - - const endpoints = [ - "/stockdeck", - "/analyst-summary-rating", - "/stock-quote", - "/pre-post-quote", - "/wiim", - "/one-day-price", - "/next-earnings", - "/earnings-surprise", - "/stock-news", - ] - - + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { - const response = await fetch(`${apiURL}${endpoint}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-API-KEY": apiKey, - }, - body: JSON.stringify({ticker, endpoints}), + const response = await fetch(url, { + ...options, signal: controller.signal }); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + return await response.json(); + } finally { + clearTimeout(timeoutId); + } +}; - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } +// Main data fetching function +const fetchData = async (apiURL, apiKey, endpoint, ticker) => { + const cacheKey = `${endpoint}-${ticker}`; + const cachedData = dataCache.get(cacheKey); + if (cachedData) return cachedData; - const data = await response.json(); - cache.set(cacheKey, { data, timestamp: Date.now() }); + const options = { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-KEY": apiKey + }, + body: JSON.stringify({ ticker, endpoints: ENDPOINTS }) + }; + + try { + const data = await fetchWithTimeout( + `${apiURL}${endpoint}`, + options, + REQUEST_TIMEOUT + ); + dataCache.set(cacheKey, data); return data; } catch (error) { if (error.name === 'AbortError') { throw new Error(`Request timeout for ${endpoint}`); } throw error; - } finally { - clearTimeout(timeoutId); } }; +// Optimized watchlist fetching with error boundary const fetchWatchlist = async (pb, userId) => { - let output; + if (!userId) return []; try { - output = await pb.collection("watchlist").getFullList({ - filter: `user="${userId}"`, + return await pb.collection("watchlist").getFullList({ + filter: `user="${userId}"` }); - } catch (e) { - //console.log(e) - output = []; + } catch { + return []; } - return output; }; +// Main load function with parallel fetching export const load = async ({ params, locals }) => { const { apiURL, apiKey, pb, user } = locals; const { tickerID } = params; @@ -95,24 +141,24 @@ export const load = async ({ params, locals }) => { } try { - // Fetch combined stock data from the '/stock-data' endpoint - const getStockData = await fetchData(apiURL, apiKey, "/bulk-data", tickerID); + // Fetch data in parallel + const [stockData, userWatchlist] = await Promise.all([ + fetchData(apiURL, apiKey, "/bulk-data", tickerID), + fetchWatchlist(pb, user?.id) + ]); - // Destructure the returned object to assign friendly names + // Destructure with default empty object to prevent undefined errors const { - '/stockdeck': getStockDeck, - '/analyst-summary-rating': getAnalystSummary, - '/stock-quote': getStockQuote, - '/pre-post-quote': getPrePostQuote, - '/wiim': getWhyPriceMoved, - '/one-day-price': getOneDayPrice, - '/next-earnings': getNextEarnings, - '/earnings-surprise': getEarningsSurprise, - '/stock-news': getNews, - } = getStockData; - - // Optionally, you can still fetch additional data like the watchlist: - const getUserWatchlist = await fetchWatchlist(pb, user?.id).catch(() => []); + '/stockdeck': getStockDeck = {}, + '/analyst-summary-rating': getAnalystSummary = {}, + '/stock-quote': getStockQuote = {}, + '/pre-post-quote': getPrePostQuote = {}, + '/wiim': getWhyPriceMoved = {}, + '/one-day-price': getOneDayPrice = {}, + '/next-earnings': getNextEarnings = {}, + '/earnings-surprise': getEarningsSurprise = {}, + '/stock-news': getNews = {} + } = stockData; return { getStockDeck, @@ -124,11 +170,11 @@ export const load = async ({ params, locals }) => { getNextEarnings, getEarningsSurprise, getNews, - getUserWatchlist, + getUserWatchlist: userWatchlist, companyName: cleanString(getStockDeck?.companyName), - getParams: tickerID, + getParams: tickerID }; } catch (error) { return { error: 'Failed to load stock data' }; } -}; +}; \ No newline at end of file