diff --git a/src/components/Landing/AppShell.tsx b/src/components/Landing/AppShell.tsx index 5c77264..de99afb 100644 --- a/src/components/Landing/AppShell.tsx +++ b/src/components/Landing/AppShell.tsx @@ -41,7 +41,7 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => { Stock Explorer @@ -89,7 +89,7 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => { Stock Explorer diff --git a/src/components/SavingsPlanSimulator.tsx b/src/components/SavingsPlanSimulator.tsx new file mode 100644 index 0000000..b55ace3 --- /dev/null +++ b/src/components/SavingsPlanSimulator.tsx @@ -0,0 +1,502 @@ +import { useState, useEffect, useRef } from 'react'; +import { format, addMonths } from 'date-fns'; +import { Asset } from '../types'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer +} from 'recharts'; + +interface SavingsPlanSimulatorProps { + stocks: Asset[]; + stockColors: Record; + initialParams?: { + monthlyAmount: number; + years: number; + allocations: Record; + }; + onParamsChange?: (params: { + monthlyAmount: number; + years: number; + allocations: Record; + }) => void; +} + +export const SavingsPlanSimulator = ({ + stocks, + stockColors, + initialParams, + onParamsChange +}: SavingsPlanSimulatorProps) => { + // Add a ref to track initial load + const initialLoadComplete = useRef(false); + const prevParamsRef = useRef<{ + monthlyAmount: number; + years: number; + allocations: string; + }>({ + monthlyAmount: 0, + years: 0, + allocations: '' + }); + + // Initialize state with props if provided + const [totalAmount, setTotalAmount] = useState(initialParams?.monthlyAmount || 1000); + const [years, setYears] = useState(initialParams?.years || 5); // Default projection for 5 years + const [allocations, setAllocations] = useState>( + initialParams?.allocations || + stocks.reduce((acc, stock) => { + acc[stock.id] = 100 / stocks.length; // Equal distribution by default + return acc; + }, {} as Record) + ); + + // Call the onParamsChange callback when parameters change + useEffect(() => { + if (onParamsChange) { + // Convert allocations to a comparable string + const allocationsString = JSON.stringify(allocations); + + // Check if anything has actually changed + const prevParams = prevParamsRef.current; + const hasChanged = + totalAmount !== prevParams.monthlyAmount || + years !== prevParams.years || + allocationsString !== prevParams.allocations; + + // Only call onParamsChange if values actually changed + if (hasChanged) { + // Update the ref with current values + prevParamsRef.current = { + monthlyAmount: totalAmount, + years, + allocations: allocationsString + }; + + // Notify parent of changes + onParamsChange({ + monthlyAmount: totalAmount, + years, + allocations + }); + } + } + }, [totalAmount, years, allocations, onParamsChange]); + + // Run simulation automatically on initial load with URL params + useEffect(() => { + // Only run on first render when we have initialParams + if (!initialLoadComplete.current && stocks.filter(stock => stock.historicalData && stock.historicalData.size >= 2).length > 0) { + initialLoadComplete.current = true; + + // Small delay to ensure all stock data is loaded + setTimeout(() => document.getElementById('runSimulationButton')?.click(), 1000); + } + }, [stocks]); + + const [simulationResults, setSimulationResults] = useState(null); + const [simulationParams, setSimulationParams] = useState<{ + monthlyAmount: number; + years: number; + allocations: Record; + } | null>(null); + + // Calculate the total allocation percentage + const totalAllocation = Object.values(allocations).reduce((sum, value) => sum + value, 0); + + // Handle allocation change for a stock + const handleAllocationChange = (stockId: string, value: number) => { + const newValue = Math.max(0, Math.min(100, value)); // Clamp between 0 and 100 + setAllocations(prev => ({ + ...prev, + [stockId]: newValue + })); + }; + + // Recalculate all allocations to sum to 100% + const normalizeAllocations = () => { + if (totalAllocation === 0) return; + + const factor = 100 / totalAllocation; + const normalized = Object.entries(allocations).reduce((acc, [id, value]) => { + acc[id] = Math.round((value * factor) * 10) / 10; // Round to 1 decimal place + return acc; + }, {} as Record); + + setAllocations(normalized); + }; + + // Run the simulation + const runSimulation = () => { + // Normalize allocations to ensure they sum to 100% + const normalizedAllocations = { ...allocations }; + if (totalAllocation !== 100) { + const factor = 100 / totalAllocation; + Object.keys(normalizedAllocations).forEach(id => { + normalizedAllocations[id] = normalizedAllocations[id] * factor; + }); + } + + // Calculate the monetary amount for each stock + const stockAmounts = Object.entries(normalizedAllocations).reduce((acc, [id, percentage]) => { + acc[id] = (percentage / 100) * totalAmount; + return acc; + }, {} as Record); + + // Calculate performance metrics + const performanceMetrics = calculatePerformanceMetrics(stocks, stockAmounts, years); + + // Save the parameters used for this simulation + setSimulationParams({ + monthlyAmount: totalAmount, + years, + allocations: normalizedAllocations + }); + + setSimulationResults(performanceMetrics); + }; + + // Helper function to calculate performance metrics + const calculatePerformanceMetrics = (stocks: Asset[], amounts: Record, projectionYears: number) => { + // Calculate expected annual return based on historical performance + let totalWeight = 0; + let weightedReturn = 0; + + const stockReturns: Record = {}; + + stocks.forEach(stock => { + // Check if the stock ID exists in the amounts object + if (!amounts[stock.id]) return; + + const weight = amounts[stock.id] / totalAmount; + if (weight > 0) { + totalWeight += weight; + + if (stock.historicalData && stock.historicalData.size >= 2) { + // Calculate annualized return the same way as in StockExplorer.tsx + const historicalData = Array.from(stock.historicalData.entries()); + + // Sort by date + historicalData.sort((a, b) => + new Date(a[0]).getTime() - new Date(b[0]).getTime() + ); + + const firstValue = historicalData[0][1]; + const lastValue = historicalData[historicalData.length - 1][1]; + + // Calculate annualized return using a more precise year duration and standard CAGR + const firstDate = new Date(historicalData[0][0]); + const lastDate = new Date(historicalData[historicalData.length - 1][0]); + const yearsDiff = (lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + + // Use CAGR formula: (Final Value / Initial Value)^(1/Years) - 1 + const annualReturn = (Math.pow(lastValue / firstValue, 1 / yearsDiff) - 1); + + stockReturns[stock.id] = annualReturn; + weightedReturn += annualReturn * weight; + } + } + }); + + // Convert the decimal to percentage for display + const expectedAnnualReturn = weightedReturn * 100; + + // Generate projection data for chart + const projectionData = []; + const today = new Date(); + + // Monthly compounding for regular investments + let totalPortfolioValue = totalAmount; // Initial investment + const stockValues: Record = {}; + + // Initialize stock values with initial investment according to allocations + stocks.forEach(stock => { + if (amounts[stock.id]) { + const initialAmount = (amounts[stock.id] / totalAmount) * totalAmount; + stockValues[stock.id] = initialAmount; + } + }); + + // Initialize variables for total investment tracking + let totalInvestment = totalAmount; // Initial investment + + // First data point is the initial investment + projectionData.push({ + date: format(today, 'MMM yyyy'), + month: 0, + portfolioValue: totalPortfolioValue, + totalInvestment, + ...stockValues + }); + + // Create monthly data points for the chart (starting from month 1) + for (let month = 1; month <= projectionYears * 12; month++) { + const date = addMonths(today, month); + + // Apply compound returns for each stock based on its expected return + stocks.forEach(stock => { + if (stockValues[stock.id] > 0) { + const baseReturn = stockReturns[stock.id] || weightedReturn; + const baseMonthlyReturn = baseReturn / 12; + + // Add some randomness to make the returns vary month to month + const monthFactor = month % 12; // To create some seasonal variation + const randomFactor = 0.5 + Math.random(); // Between 0.5 and 1.5 + const seasonalFactor = 1 + (Math.sin(monthFactor / 12 * Math.PI * 2) * 0.2); // +/- 20% seasonal effect + + const monthlyReturn = baseMonthlyReturn * randomFactor * seasonalFactor; + + // Apply the monthly return to the current stock value (compound interest) + stockValues[stock.id] *= (1 + monthlyReturn); + } + }); + + // Add new monthly investment according to allocation percentages + Object.entries(amounts).forEach(([id, amount]) => { + if (stockValues[id] !== undefined) { + const investmentAmount = (amount / totalAmount) * totalAmount; + stockValues[id] += investmentAmount; + } + }); + + // Calculate total portfolio value after this month + totalPortfolioValue = Object.values(stockValues).reduce((sum, val) => sum + val, 0); + + // Add the monthly contribution to the total investment amount + totalInvestment += totalAmount; + + // Create data point for this month + const dataPoint: any = { + date: format(date, 'MMM yyyy'), + month, + portfolioValue: totalPortfolioValue, + totalInvestment, + ...stockValues + }; + + projectionData.push(dataPoint); + } + + return { + expectedAnnualReturn, + portfolioValue: totalPortfolioValue, + totalInvestment, + stockValues, + projectionData + }; + }; + + // Helper function: map a return to a color. + // Negative returns will be red, positive green, with yellows in between. + const getReturnColor = (ret: number) => { + const clamp = (num: number, min: number, max: number) => Math.max(min, Math.min(num, max)); + // Normalize so that -10% maps to 0, 0% to 0.5, and +10% to 1. (Adjust these as needed) + const normalized = clamp((ret + 0.1) / 0.2, 0, 1); + const interpolateColor = (color1: string, color2: string, factor: number): string => { + const c1 = color1.slice(1).match(/.{2}/g)!.map(hex => parseInt(hex, 16)); + const c2 = color2.slice(1).match(/.{2}/g)!.map(hex => parseInt(hex, 16)); + const r = Math.round(c1[0] + factor * (c2[0] - c1[0])); + const g = Math.round(c1[1] + factor * (c2[1] - c1[1])); + const b = Math.round(c1[2] + factor * (c2[2] - c1[2])); + return `rgb(${r}, ${g}, ${b})`; + }; + + // Interpolate from red (#ff0000) to yellow (#ffff00) then yellow to green (#00ff00) + if (normalized <= 0.5) { + const factor = normalized / 0.5; + return interpolateColor("#ff0000", "#ffff00", factor); + } else { + const factor = (normalized - 0.5) / 0.5; + return interpolateColor("#ffff00", "#00ff00", factor); + } + }; + + // Add a TIME_PERIODS constant based on StockExplorer's implementation + const TIME_PERIODS = { + "MTD": "Month to Date", + "1M": "1 Month", + "3M": "3 Months", + "6M": "6 Months", + "YTD": "Year to Date", + "1Y": "1 Year", + "3Y": "3 Years", + "5Y": "5 Years", + "10Y": "10 Years", + "MAX": "Max" + }; + + return ( +
+

Savings Plan Simulator

+ +
+
+
+
+ +
+ setTotalAmount(Math.max(0, Number(e.target.value)))} + className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-full" + /> + +
+
+ +
+ + setYears(Math.max(0, Number(e.target.value)))} + className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-full" + /> +
+
+ +
+

Allocation Percentages

+
+
+ {stocks.map(stock => ( +
+
+ {stock.name} + handleAllocationChange(stock.id, Number(e.target.value))} + className="border p-1 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 w-16 text-right" + /> + % +
+ ))} +
+ +
+
+ Total Allocation: {totalAllocation.toFixed(1)}% +
+ +
+
+
+ + +
+ + {simulationResults && simulationParams && ( +
+ {/* Modified Information Boxes - Now 5 boxes in total */} +
+
+

+ Avg. Yearly Return +

+

= 0 ? + 'var(--color-success, #10b981)' : + 'var(--color-danger, #ef4444)' + }}> + {simulationResults.expectedAnnualReturn.toFixed(2)}% +

+
+
+

+ Monthly Investment +

+

+ €{simulationParams.monthlyAmount.toLocaleString()} +

+
+
+

+ Total Invested ({simulationParams.years} years) +

+

+ €{(simulationParams.monthlyAmount * simulationParams.years * 12).toLocaleString()} +

+
+
+

+ Projected Portfolio Value +

+

+ €{Math.round(simulationResults.portfolioValue).toLocaleString()} +

+
+
+

+ Total Gain +

+

+ €{Math.round(simulationResults.portfolioValue - (simulationParams.monthlyAmount * simulationParams.years * 12)).toLocaleString()} +

+

+ {(((simulationResults.portfolioValue / (simulationParams.monthlyAmount * simulationParams.years * 12)) - 1) * 100).toFixed(1)}% +

+
+
+ + {/* Full-Width Chart */} +
+ + + + format(new Date(date), 'MMM yyyy')} + tick={{ fill: '#4E4E4E' }} + /> + + [`€${Math.round(value).toLocaleString()}`, 'Value']} + labelFormatter={(label) => label} + /> + + + + + +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/SortableTable.tsx b/src/components/SortableTable.tsx new file mode 100644 index 0000000..2573311 --- /dev/null +++ b/src/components/SortableTable.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; + +interface SortableTableProps { + data: any[]; + columns: { + key: string; + label: string; + sortable?: boolean; + render?: (value: any, row: any) => React.ReactNode; + }[]; +} + +export const SortableTable = ({ data, columns }: SortableTableProps) => { + const [sortConfig, setSortConfig] = useState<{ + key: string; + direction: 'ascending' | 'descending'; + } | null>(null); + + const sortedData = React.useMemo(() => { + // Create a copy of the data to avoid mutating the original + let sortableData = [...data]; + if (sortConfig !== null) { + sortableData.sort((a, b) => { + // Handle null and undefined values + if (a[sortConfig.key] == null) return sortConfig.direction === 'ascending' ? -1 : 1; + if (b[sortConfig.key] == null) return sortConfig.direction === 'ascending' ? 1 : -1; + + // Handle numeric values + if (typeof a[sortConfig.key] === 'number' && typeof b[sortConfig.key] === 'number') { + return sortConfig.direction === 'ascending' + ? a[sortConfig.key] - b[sortConfig.key] + : b[sortConfig.key] - a[sortConfig.key]; + } + + // Handle string values + if (typeof a[sortConfig.key] === 'string' && typeof b[sortConfig.key] === 'string') { + return sortConfig.direction === 'ascending' + ? a[sortConfig.key].localeCompare(b[sortConfig.key]) + : b[sortConfig.key].localeCompare(a[sortConfig.key]); + } + + // Fallback for other types + return 0; + }); + } + return sortableData; + }, [data, sortConfig]); + + const requestSort = (key: string) => { + let direction: 'ascending' | 'descending' = 'ascending'; + if (sortConfig?.key === key && sortConfig.direction === 'ascending') { + direction = 'descending'; + } + setSortConfig({ key, direction }); + }; + + return ( + + + + {columns.map((column) => ( + + ))} + + + + {sortedData.map((row, index) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
column.sortable !== false && requestSort(column.key)} + > +
+ {column.label} + {sortConfig?.key === column.key && ( + + {sortConfig.direction === 'ascending' ? ( + + ) : ( + + )} + + )} +
+
+ {column.render ? column.render(row[column.key], row) : row[column.key]} +
+ ); +}; \ No newline at end of file diff --git a/src/hooks/useLivePrice.ts b/src/hooks/useLivePrice.ts new file mode 100644 index 0000000..f60050d --- /dev/null +++ b/src/hooks/useLivePrice.ts @@ -0,0 +1,86 @@ +import { useState, useEffect, useRef } from 'react'; +import { getHistoricalData } from '../services/yahooFinanceService'; + +interface LivePriceOptions { + symbol: string; + refreshInterval?: number; // in milliseconds, default 60000 (1 minute) + enabled?: boolean; +} + +export function useLivePrice({ symbol, refreshInterval = 60000, enabled = true }: LivePriceOptions) { + const [livePrice, setLivePrice] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [lastPrice, setLastPrice] = useState(null); + const [usedCurrency, setUsedCurrency] = useState(null); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const timerRef = useRef(null); + + const fetchLivePrice = async () => { + if (!symbol || !enabled) return; + + setIsLoading(true); + setError(null); + + try { + // Calculate time range for the last 10 minutes + const endDate = new Date(); + const startDate = new Date(endDate.getTime() - 10 * 60 * 1000); // 10 minutes ago + + const { historicalData, lastPrice, currency } = await getHistoricalData( + symbol, + startDate, + endDate, + "1m" // 1-minute interval + ); + + if(!usedCurrency) { + setUsedCurrency(currency ?? null); + } + // Get the most recent price + if (historicalData.size > 0) { + const entries = Array.from(historicalData.entries()); + const latestEntry = entries[entries.length - 1]; + setLivePrice(latestEntry[1]); + setLastUpdated(new Date()); + setLastPrice(null); + } else { + setLastPrice(lastPrice ?? null); + } + } catch (err) { + console.error(`Error fetching live price for ${symbol}:`, err); + setError(err instanceof Error ? err : new Error('Failed to fetch live price')); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + // Initial fetch + if (enabled) { + fetchLivePrice(); + } + + // Set up interval for periodic updates + if (enabled && refreshInterval > 0) { + timerRef.current = window.setInterval(fetchLivePrice, lastPrice ? refreshInterval : refreshInterval * 10); + } + + // Cleanup + return () => { + if (timerRef.current !== null) { + clearInterval(timerRef.current); + } + }; + }, [symbol, refreshInterval, enabled]); + + return { + livePrice, + isLoading, + error, + lastUpdated, + lastPrice, + currency: usedCurrency, + refetch: fetchLivePrice + }; +} \ No newline at end of file diff --git a/src/pages/StockExplorer.tsx b/src/pages/StockExplorer.tsx index 772cd69..f39abcb 100644 --- a/src/pages/StockExplorer.tsx +++ b/src/pages/StockExplorer.tsx @@ -1,28 +1,36 @@ -import { format, subYears } from "date-fns"; -import { ChevronDown, ChevronLeft, Filter, Plus, RefreshCw, Search, X } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { format, subYears, subMonths } from "date-fns"; +import { ChevronDown, ChevronLeft, Circle, Filter, Heart, Plus, RefreshCw, Search, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import { - CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis + CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { useDarkMode } from "../hooks/useDarkMode"; import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../services/yahooFinanceService"; import { Asset } from "../types"; -import { getHexColor } from "../utils/formatters"; +import { formatCurrency, getHexColor } from "../utils/formatters"; import { intervalBasedOnDateRange } from "../utils/calculations/intervalBasedOnDateRange"; -// Time period options -const TIME_PERIODS = { - YTD: "Year to Date", +import { useLivePrice } from '../hooks/useLivePrice'; +import { SortableTable } from '../components/SortableTable'; +import { SavingsPlanSimulator } from '../components/SavingsPlanSimulator'; + +// Extended time period options +const TIME_PERIODS: { [key: string]: string } = { + "MTD": "Month To Date", + "1M": "1 Month", + "3M": "3 Months", + "6M": "6 Months", + "YTD": "Year To Date", "1Y": "1 Year", "3Y": "3 Years", "5Y": "5 Years", "10Y": "10 Years", "15Y": "15 Years", "20Y": "20 Years", - MAX: "Maximum", - CUSTOM: "Custom Range" + "MAX": "Max", + "CUSTOM": "Custom", }; // Equity type options @@ -39,16 +47,18 @@ const EQUITY_TYPESMAP: Record = { }; const StockExplorer = () => { + const initialFetch = useRef(false); + const parsedAllocationsRef = useRef>({}); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); - const [selectedStocks, setSelectedStocks] = useState([]); + const [selectedStocks, setSelectedStocks] = useState<(Asset & { currency?: string | null })[]>([]); const [loading, setLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false); - const [timePeriod, setTimePeriod] = useState("1Y"); + const [timePeriod, setTimePeriod] = useState("YTD"); const [equityType, setEquityType] = useState("all"); const [showEquityTypeDropdown, setShowEquityTypeDropdown] = useState(false); - const [dateRange, setDateRange] = useState({ - startDate: subYears(new Date(), 1), + const [dateRange, setDateRange] = useState<{ startDate: Date; endDate: Date }>({ + startDate: new Date(new Date().getFullYear(), 0, 1), endDate: new Date() }); const [customDateRange, setCustomDateRange] = useState({ @@ -58,6 +68,233 @@ const StockExplorer = () => { const [stockData, setStockData] = useState([]); const [stockColors, setStockColors] = useState>({}); const { isDarkMode } = useDarkMode(); + const [showSearchBar, setShowSearchBar] = useState(true); + const [searchParams, setSearchParams] = useSearchParams(); + const previousParamsRef = useRef<{ + stocks: string[]; + period: string; + monthlyAmount: number; + projectionYears: number; + allocations: string; + }>({ + stocks: [], + period: '', + monthlyAmount: 0, + projectionYears: 0, + allocations: '' + }); + const [savingsPlanParams, setSavingsPlanParams] = useState({ + monthlyAmount: 1000, + years: 5, + allocations: {} as Record + }); + + // On mount: Read URL query params and update states if found. + useEffect(() => { + if (initialFetch.current) return; + initialFetch.current = true; + + const periodParam = searchParams.get("period"); + if (periodParam && Object.keys(TIME_PERIODS).includes(periodParam)) { + setTimePeriod(periodParam as keyof typeof TIME_PERIODS); + updateTimePeriod(periodParam as keyof typeof TIME_PERIODS); + } + + // Get savings plan params first so they're ready when stocks load + const monthlyAmountParam = searchParams.get("monthlyAmount"); + const yearsParam = searchParams.get("projectionYears"); + const allocationsParam = searchParams.get("allocations"); + + let newSavingsPlanParams = {...savingsPlanParams}; + + if (monthlyAmountParam) { + newSavingsPlanParams.monthlyAmount = Number(monthlyAmountParam); + } + + if (yearsParam) { + newSavingsPlanParams.years = Number(yearsParam); + } + + // Parse allocations but don't apply them yet - we'll do that after stocks load + let parsedAllocations: Record = {}; + if (allocationsParam) { + try { + const allocationPairs = allocationsParam.split(','); + allocationPairs.forEach(pair => { + const [id, percentage] = pair.split(':'); + if (id && percentage) { + parsedAllocations[id] = Number(percentage); + } + }); + } catch (e) { + console.error("Failed to parse allocations param:", e); + } + } + + // Update the ref value instead of creating a new ref + parsedAllocationsRef.current = parsedAllocations; + + // Handle loading stocks from URL + const stocksParam = searchParams.get("stocks"); + if (stocksParam) { + const symbols = stocksParam.split(","); + if (symbols.length > 0) { + setLoading(true); + + // Load all stocks first, then apply allocations + (async () => { + try { + // Create an array to hold all loaded stocks + const loadedStocks: Asset[] = []; + + // Process in batches to avoid rate limiting + const batchSize = 3; + const batches = []; + + for (let i = 0; i < symbols.length; i += batchSize) { + batches.push(symbols.slice(i, i + batchSize)); + } + + for (const batch of batches) { + // Process each batch sequentially + const batchResults = await Promise.all( + batch.map(async (symbol) => { + const results = await searchAssets(symbol, EQUITY_TYPES[equityType]); + if (results.length > 0) { + // Get historical data for the stock + const stock = results[0]; + const histData = await getHistoricalData( + stock.symbol, + dateRange.startDate, + dateRange.endDate + ); + + // Create the complete stock object + return { + ...stock, + ...histData + } as unknown as Asset; + } + return null; + }) + ); + + // Add valid results to loadedStocks + batchResults.filter(Boolean).forEach(stock => { + if (stock) loadedStocks.push(stock); + }); + } + + // Now that all stocks are loaded, set them all at once + if (loadedStocks.length > 0) { + setSelectedStocks(loadedStocks); + + // Assign colors to the stocks + const colors: Record = {}; + loadedStocks.forEach((stock) => { + colors[stock.id] = getHexColor(new Set(Object.values(colors)), isDarkMode); + }); + setStockColors(colors); + + // Now apply the allocations + if (Object.keys(parsedAllocationsRef.current).length > 0) { + // Check if we have allocations for all stocks, otherwise use equal distribution + const hasAllAllocations = loadedStocks.every( + stock => parsedAllocationsRef.current[stock.id] !== undefined + ); + + if (hasAllAllocations) { + newSavingsPlanParams.allocations = parsedAllocationsRef.current; + } else { + // Fallback to equal distribution + loadedStocks.forEach(stock => { + newSavingsPlanParams.allocations[stock.id] = 100 / loadedStocks.length; + }); + } + } else { + // No allocations in URL, use equal distribution + loadedStocks.forEach(stock => { + newSavingsPlanParams.allocations[stock.id] = 100 / loadedStocks.length; + }); + } + + // Apply the final savings plan params + setSavingsPlanParams(newSavingsPlanParams); + } + } catch (error) { + console.error("Error loading stocks from URL:", error); + toast.error("Failed to load some stocks from URL"); + } finally { + setLoading(false); + } + })(); + } else { + // No stocks to load, just set the savings plan params + setSavingsPlanParams(newSavingsPlanParams); + } + } else { + // No stocks to load, just set the savings plan params + setSavingsPlanParams(newSavingsPlanParams); + } + }, []); // Empty dependency array for mount only + + // Update URL query params when selectedStocks or timePeriod changes. + useEffect(() => { + // Create the new params object + const params: Record = {}; + const stockSymbols = selectedStocks.map(stock => stock.symbol); + const stocksParam = stockSymbols.join(","); + + // Only add parameters if they have values + if (selectedStocks.length > 0) { + params.stocks = stocksParam; + } + if (timePeriod) { + params.period = timePeriod.toString(); + } + if (savingsPlanParams.monthlyAmount > 0) { + params.monthlyAmount = savingsPlanParams.monthlyAmount.toString(); + } + if (savingsPlanParams.years > 0) { + params.projectionYears = savingsPlanParams.years.toString(); + } + + // Process allocations + const allocationEntries = Object.entries(savingsPlanParams.allocations) + .filter(([id, percentage]) => { + return selectedStocks.some(stock => stock.id === id) && percentage > 0; + }) + .map(([id, percentage]) => `${id}:${Math.round(percentage * 10) / 10}`); + + const allocationsParam = allocationEntries.join(','); + if (allocationEntries.length > 0) { + params.allocations = allocationsParam; + } + + // Check if anything actually changed before updating URL + const prevParams = previousParamsRef.current; + const hasChanged = + stocksParam !== prevParams.stocks.join(",") || + timePeriod.toString() !== prevParams.period || + savingsPlanParams.monthlyAmount !== prevParams.monthlyAmount || + savingsPlanParams.years !== prevParams.projectionYears || + allocationsParam !== prevParams.allocations; + + // Only update URL if something changed + if (hasChanged) { + // Update the ref with new values + previousParamsRef.current = { + stocks: stockSymbols, + period: timePeriod.toString(), + monthlyAmount: savingsPlanParams.monthlyAmount, + projectionYears: savingsPlanParams.years, + allocations: allocationsParam + }; + + // Update the URL params + setSearchParams(params); + } + }, [selectedStocks, timePeriod, savingsPlanParams, setSearchParams]); // Handle search const handleSearch = useCallback(async () => { @@ -72,12 +309,8 @@ const StockExplorer = () => { // Convert the equity type to a comma-separated string for the API const typeParam = EQUITY_TYPES[equityType]; - console.log(`Searching for "${searchQuery}" with type "${typeParam}"`); - const results = await searchAssets(searchQuery, typeParam); - console.log("Search results:", results); - // Filter out stocks already in the selected list const filteredResults = results.filter( result => !selectedStocks.some(stock => stock.symbol === result.symbol) @@ -120,11 +353,11 @@ const StockExplorer = () => { setLoading(true); try { // Use a more efficient date range for initial load - const optimizedStartDate = timePeriod === "MAX" || timePeriod === "20Y" || timePeriod === "15Y" || timePeriod === "10Y" + const optimizedStartDate = timePeriod === "MAX" || timePeriod === "20Y" || timePeriod === "15Y" || timePeriod === "10Y" ? new Date(dateRange.startDate.getTime() + (365 * 24 * 60 * 60 * 1000)) // Skip first year for very long ranges : dateRange.startDate; - - const { historicalData, longName } = await getHistoricalData( + + const { historicalData, longName, currency } = await getHistoricalData( stock.symbol, optimizedStartDate, dateRange.endDate, @@ -138,6 +371,7 @@ const StockExplorer = () => { const stockWithHistory = { ...stock, + currency: currency, name: longName || stock.name, historicalData, investments: [] // Empty as we're just exploring @@ -154,7 +388,8 @@ const StockExplorer = () => { }); // Don't clear results, so users can add multiple stocks - setSearchResults(prev => prev.filter(result => result.symbol !== stock.symbol)); + //setSearchResults(prev => prev.filter(result => result.symbol !== stock.symbol)); + setSearchResults([]); toast.success(`Added ${stockWithHistory.name} to comparison`); } catch (error) { @@ -173,13 +408,23 @@ const StockExplorer = () => { // Update time period and date range const updateTimePeriod = useCallback((period: keyof typeof TIME_PERIODS) => { setTimePeriod(period); - const endDate = new Date(); - let startDate; - + let startDate: Date; switch (period) { + case "MTD": + startDate = new Date(endDate.getFullYear(), endDate.getMonth(), 1); + break; + case "1M": + startDate = subMonths(endDate, 1); + break; + case "3M": + startDate = subMonths(endDate, 3); + break; + case "6M": + startDate = subMonths(endDate, 6); + break; case "YTD": - startDate = new Date(endDate.getFullYear(), 0, 1); // Jan 1 of current year + startDate = new Date(endDate.getFullYear(), 0, 1); break; case "1Y": startDate = subYears(endDate, 1); @@ -209,7 +454,6 @@ const StockExplorer = () => { default: startDate = subYears(endDate, 1); } - if (period !== "CUSTOM") { setDateRange({ startDate, endDate }); } else { @@ -217,6 +461,49 @@ const StockExplorer = () => { } }, [customDateRange]); + // Helper: Interpolate missing daily data so chart lines look connected + // Assumes each data point is an object with a "date" property and one or more stock keys (e.g. "stockId_percent"). + const interpolateStockSeries = (data: any[]): any[] => { + if (data.length === 0) return data; + const interpolated = [...data]; + // Get all keys other than "date" + const keys = Object.keys(data[0]).filter(key => key !== "date"); + keys.forEach(key => { + // Loop over data points and fill missing values + for (let i = 0; i < interpolated.length; i++) { + if (interpolated[i][key] === undefined || interpolated[i][key] === null) { + // Find the previous non-null value + let prevIndex = i - 1; + while (prevIndex >= 0 && (interpolated[prevIndex][key] === undefined || interpolated[prevIndex][key] === null)) { + prevIndex--; + } + // Find the next non-null value + let nextIndex = i + 1; + while (nextIndex < interpolated.length && (interpolated[nextIndex][key] === undefined || interpolated[nextIndex][key] === null)) { + nextIndex++; + } + if (prevIndex >= 0 && nextIndex < interpolated.length) { + const prevVal = interpolated[prevIndex][key]; + const nextVal = interpolated[nextIndex][key]; + const fraction = (i - prevIndex) / (nextIndex - prevIndex); + interpolated[i][key] = prevVal + (nextVal - prevVal) * fraction; + } else if (prevIndex >= 0) { + interpolated[i][key] = interpolated[prevIndex][key]; + } else if (nextIndex < interpolated.length) { + interpolated[i][key] = interpolated[nextIndex][key]; + } else { + interpolated[i][key] = 0; + } + } + } + }); + return interpolated; + }; + + // When stockData is ready, create an interpolated version for the chart. + // (Assume stockData is computed elsewhere in this component.) + const interpolatedStockData = interpolateStockSeries(stockData); + // Process the stock data for display const processStockData = useCallback((stocks: Asset[]) => { // Create a combined dataset with data points for all dates @@ -264,21 +551,21 @@ const StockExplorer = () => { // Refresh stock data when stocks or date range changes const refreshStockData = useCallback(async () => { if (selectedStocks.length === 0) return; - + setLoading(true); try { // Process in batches for better performance const batchSize = 3; const batches = []; - + for (let i = 0; i < selectedStocks.length; i += batchSize) { batches.push(selectedStocks.slice(i, i + batchSize)); } - + const updatedStocks = [...selectedStocks]; - + for (const batch of batches) { - await Promise.all(batch.map(async (stock, index) => { + await Promise.all(batch.map(async (stock) => { try { const { historicalData, longName } = await getHistoricalData( stock.symbol, @@ -286,7 +573,7 @@ const StockExplorer = () => { dateRange.endDate, intervalBasedOnDateRange({ startDate: dateRange.startDate, endDate: dateRange.endDate }) ); - + if (historicalData.size > 0) { updatedStocks[selectedStocks.indexOf(stock)] = { ...stock, @@ -299,10 +586,10 @@ const StockExplorer = () => { } })); } - + setSelectedStocks(updatedStocks); processStockData(updatedStocks); - + } catch (error) { console.error("Error refreshing data:", error); toast.error("Failed to refresh stock data"); @@ -338,8 +625,8 @@ const StockExplorer = () => { const annualizedReturn = (Math.pow(lastValue / firstValue, 1 / yearsDiff) - 1) * 100; return { - total: `${totalPercentChange.toFixed(2)}%`, - annualized: `${annualizedReturn.toFixed(2)}%/year`, + total: `${totalPercentChange.toFixed(2)} %`, + annualized: `${annualizedReturn.toFixed(2)} %/y`, }; }, []); @@ -359,14 +646,6 @@ const StockExplorer = () => { } }, [timePeriod]); - // Add debugging for chart display - useEffect(() => { - if (selectedStocks.length > 0) { - console.log("Selected stocks:", selectedStocks); - console.log("Stock data for chart:", stockData); - } - }, [selectedStocks, stockData]); - // Ensure processStockData is called immediately when selectedStocks changes useEffect(() => { if (selectedStocks.length > 0) { @@ -375,6 +654,94 @@ const StockExplorer = () => { } }, [selectedStocks, processStockData]); + const LivePriceCell = ({ symbol }: { symbol: string }) => { + const { livePrice, isLoading, lastUpdated, lastPrice, currency } = useLivePrice({ + symbol, + refreshInterval: 60000, // 1 minute + enabled: true + }); + + return ( +
+ {isLoading ? ( + + ) : livePrice ? ( +
+
+ +
{formatCurrency(livePrice, currency)}
+
+ {lastUpdated ? `Updated: ${format(lastUpdated, 'HH:mm:ss')}` : ''} +
+
+
+ ) : ( +
+ Market Closed, last Price: {formatCurrency(lastPrice, currency)} +
+ )} +
+ ); + }; + + // Define column configurations for the sortable table + const tableColumns = [ + { + key: 'name', + label: 'Stock', + render: (value: string, row: any) => ( +
+
+ {value} +
+ ) + }, + { + key: 'total', + label: 'Total Return', + sortable: true + }, + { + key: 'annualized', + label: 'Annualized Return', + sortable: true + }, + { + key: 'currentPrice', + label: 'Current Price (last day)', + sortable: true, + render: (value: number, row: any) => formatCurrency(value, row.currency) + }, + { + key: 'symbol', + label: 'Live Price', + sortable: false, + render: (value: string) => + } + ]; + + // Prepare data for the table + const tableData = selectedStocks.map(stock => { + const metrics = calculatePerformanceMetrics(stock); + const historicalData = Array.from(stock.historicalData.entries()); + const currentPrice = historicalData.length > 0 + ? historicalData[historicalData.length - 1][1] + : 0; + + return { + id: stock.id, + name: stock.name, + symbol: stock.symbol, + total: metrics.total, + annualized: metrics.annualized, + currentPrice, + currency: stock.currency + }; + }); + return (
@@ -389,139 +756,152 @@ const StockExplorer = () => {

Stock Explorer

+ {/* Toggle button to show/hide the search bar */} + + {/* Conditionally render the search bar */} {/* Search and add stocks */}
-

Add Assets to Compare

- -
-
- setSearchQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Search for stocks, ETFs, indices..." - className="w-full p-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-700 dark:text-white dark:border-slate-600" - /> - {searchLoading && ( -
-
+
+

Search & Add Assets to Compare

+ +
+ {showSearchBar && ( + <> +
+
+ setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search for stocks, ETFs, indices..." + className="w-full p-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-700 dark:text-white dark:border-slate-600" + /> + {searchLoading && ( +
+
+
+ )}
- )} -
- {/* Equity Type Dropdown */} -
- + {/* Equity Type Dropdown */} +
+ - {showEquityTypeDropdown && ( -
- {Object.entries(EQUITY_TYPESMAP).map(([key, label]) => ( - + ))} +
+ )} +
+ + +
+ + {/* Search results */} + {searchResults.length > 0 && ( +
+
+ + + {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} found for "{searchQuery}" + +
+ {searchResults.map(result => ( +
+
+
{result.name}
+
+ {result.symbol} | {result.quoteType?.toUpperCase() || "Unknown"} + {result.isin && ` | ${result.isin}`} + {result.price && ` | ${result.price}`} + {result.priceChangePercent && ` | ${result.priceChangePercent}`} +
+
+
+ +
+
))}
)} -
- -
- - {/* Search results */} - {searchResults.length > 0 && ( -
-
- - {searchResults.length} result{searchResults.length !== 1 ? 's' : ''} found for "{searchQuery}" - -
- {searchResults.map(result => ( -
-
-
{result.name}
-
- {result.symbol} | {result.quoteType?.toUpperCase() || "Unknown"} - {result.isin && ` | ${result.isin}`} - {result.price && ` | ${result.price}`} - {result.priceChangePercent && ` | ${result.priceChangePercent}`} -
+ {/* Selected stocks */} +
+

Selected Stocks

+ {selectedStocks.length === 0 ? ( +

No stocks selected for comparison

+ ) : ( +
+ {selectedStocks.map(stock => { + const metrics = calculatePerformanceMetrics(stock); + return ( +
+
+ {stock.name} + + ({metrics.total}) + + +
+ ); + })}
-
- -
-
- ))} -
- )} - - {/* Selected stocks */} -
-

Selected Stocks

- {selectedStocks.length === 0 ? ( -

No stocks selected for comparison

- ) : ( -
- {selectedStocks.map(stock => { - const metrics = calculatePerformanceMetrics(stock); - return ( -
-
- {stock.name} - - ({metrics.total}) - - -
- ); - })} + )}
- )} -
+ )}
{/* Time period selector */} @@ -542,14 +922,14 @@ const StockExplorer = () => {
-
+
{Object.entries(TIME_PERIODS).map(([key, label]) => (
- {/* Custom date range selector (only visible when CUSTOM is selected) */} + {/* Custom date range selector (if CUSTOM is selected) */} {timePeriod === "CUSTOM" && ( -
+
- + handleCustomDateChange( - new Date(e.target.value), - customDateRange.endDate - )} + onChange={(e) => + handleCustomDateChange(new Date(e.target.value), customDateRange.endDate) + } className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600" />
- + handleCustomDateChange( - customDateRange.startDate, - new Date(e.target.value) - )} + onChange={(e) => + handleCustomDateChange(customDateRange.startDate, new Date(e.target.value)) + } max={format(new Date(), 'yyyy-MM-dd')} className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600" /> @@ -588,8 +970,10 @@ const StockExplorer = () => {
)} -
- Showing data from {format(dateRange.startDate, 'MMM d, yyyy')} to {format(dateRange.endDate, 'MMM d, yyyy')} +
+ Data-Interval: {intervalBasedOnDateRange({ startDate: dateRange.startDate, endDate: dateRange.endDate })} + Showing data from {format(dateRange.startDate, 'MMM d, yyyy')} to{' '} + {format(dateRange.endDate, 'MMM d, yyyy')}
@@ -597,12 +981,14 @@ const StockExplorer = () => { {selectedStocks.length > 0 && stockData.length > 0 && (
-

Performance Comparison

+

+ Performance Comparison +

- + { formatter={(value: number, name: string, props: any) => { const stockId = name.replace('_percent', ''); const price = props.payload[stockId] || 0; - const stockName = selectedStocks.find(s => s.id === stockId)?.name || name; - return [ - `${value.toFixed(2)}% (€${price.toFixed(2)})`, - stockName - ]; + const stockName = + selectedStocks.find((s) => s.id === stockId)?.name || name; + return [`${value.toFixed(2)}% (€${price.toFixed(2)})`, stockName]; }} labelFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')} /> - - {/* Only percentage lines */} - {selectedStocks.map(stock => ( + {/* Render one line per selected stock (using interpolated data) */} + {selectedStocks.map((stock) => ( { {/* Performance metrics table */}
- - - - - - - - - - - {selectedStocks.map(stock => { - const metrics = calculatePerformanceMetrics(stock); - const historicalData = Array.from(stock.historicalData.entries()); - const currentPrice = historicalData.length > 0 - ? historicalData[historicalData.length - 1][1] - : 0; - - return ( - - - - - - - ); - })} - -
StockTotal ReturnAnnualized ReturnCurrent Price
-
-
- {stock.name} -
-
{metrics.total}{metrics.annualized}€{currentPrice.toFixed(2)}
+
)} + + {/* Savings Plan Simulator */} + {selectedStocks.length > 0 && ( + + )}
- - {/* Made with Love Badge */} -
- Made with ❤️ by 0xroko -
+ + + Built with by Tomato6966 +
); }; diff --git a/src/services/yahooFinanceService.ts b/src/services/yahooFinanceService.ts index b762c8f..1cfb56c 100644 --- a/src/services/yahooFinanceService.ts +++ b/src/services/yahooFinanceService.ts @@ -25,8 +25,6 @@ export const EQUITY_TYPES = { export const searchAssets = async (query: string, equityType: string): Promise => { try { // Log input parameters for debugging - console.log(`Searching for "${query}" with type "${equityType}"`); - const params = new URLSearchParams({ query, lang: 'en-US', @@ -35,7 +33,6 @@ export const searchAssets = async (query: string, equityType: string): Promise { const matches = equityTypes.includes(quote.quoteType.toLowerCase()); - if (!matches) { - console.log(`Filtering out ${quote.symbol} (${quote.quoteType}) as it doesn't match ${equityTypes.join(', ')}`); - } return matches; }) .map((quote) => ({ @@ -104,14 +96,16 @@ export const getHistoricalData = async (symbol: string, startDate: Date, endDate if (!response.ok) throw new Error(`Network response was not ok (${response.status} - ${response.statusText} - ${await response.text().catch(() => 'No text')})`); const data = await response.json(); - const { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult; + const { timestamp = [], indicators, meta } = data.chart.result[0] as YahooChartResult; const quotes = indicators.quote[0]; const lessThenADay = ["60m", "1h", "90m", "45m", "30m", "15m", "5m", "2m", "1m"].includes(interval); return { historicalData: new Map(timestamp.map((time: number, index: number) => [formatDateToISO(new Date(time * 1000), lessThenADay), quotes.close[index]])), - longName: meta.longName + longName: meta.longName, + currency: meta.currency, + lastPrice: meta.chartPreviousClose } } catch (error) { console.error('Error fetching historical data:', error); diff --git a/src/utils/calculations/intervalBasedOnDateRange.ts b/src/utils/calculations/intervalBasedOnDateRange.ts index bb7e446..77abbc2 100644 --- a/src/utils/calculations/intervalBasedOnDateRange.ts +++ b/src/utils/calculations/intervalBasedOnDateRange.ts @@ -12,8 +12,7 @@ export const intervalBasedOnDateRange = (dateRange: DateRange, withSubDays: bool if(withSubDays && diffDays > 60 && diffDays < 100) return "1h"; if(diffDays < oneYear * 2.5) return "1d"; if(diffDays < oneYear * 6 && diffDays >= oneYear * 2.5) return "5d"; - if(diffDays < oneYear * 15 && diffDays >= oneYear * 6) return "1wk"; - if(diffDays >= oneYear * 30) return "1mo"; - return "1d"; + if(diffDays <= oneYear * 15 && diffDays >= oneYear * 6) return "1wk"; + if(diffDays >= 20) return "1mo"; } diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 2ac2300..f475ff6 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -1,10 +1,17 @@ import { formatDate, isValid, parseISO } from "date-fns"; -export const formatCurrency = (value: number): string => { - return `€${value.toLocaleString('de-DE', { +const currencyFormatter = (currency: string|null) => { + if(currency?.toUpperCase() === "USD") return "$"; + if(currency?.toUpperCase() === "GBP") return "£"; + if(currency?.toUpperCase() === "EUR") return "€"; + return currency ?? "🪙"; +} + +export const formatCurrency = (value: number|undefined|null, currencyString:string|null = "€"): string => { + return `${currencyFormatter(currencyString)} ${value?.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 - })}`; + }) ?? "N/A"}`; }; const LIGHT_MODE_COLORS = [