mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-07 11:50:36 +02:00
Enhance Stock Explorer with advanced features and URL state management
This commit is contained in:
parent
751b89d90d
commit
1562d3a120
8 changed files with 1295 additions and 253 deletions
|
@ -41,7 +41,7 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
|||
</button>
|
||||
<Link
|
||||
to="/explore"
|
||||
className="flex items-center gap-1 md:gap-2 px-3 py-2 md:px-4 md:py-2 text-sm md:text-base text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
className="flex items-center gap-1 bg-red-500/50 md:gap-2 px-3 py-2 md:px-4 md:py-2 text-sm md:text-base text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
<BarChart2 className="w-4 h-4 md:w-5 md:h-5" />
|
||||
Stock Explorer
|
||||
|
@ -89,7 +89,7 @@ export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
|||
</button>
|
||||
<Link
|
||||
to="/explore"
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded w-full border border-gray-200 dark:border-gray-700"
|
||||
className="flex items-center justify-center bg-red-500/50 gap-2 px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded w-full border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<BarChart2 className="w-5 h-5" />
|
||||
Stock Explorer
|
||||
|
|
502
src/components/SavingsPlanSimulator.tsx
Normal file
502
src/components/SavingsPlanSimulator.tsx
Normal file
|
@ -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<string, string>;
|
||||
initialParams?: {
|
||||
monthlyAmount: number;
|
||||
years: number;
|
||||
allocations: Record<string, number>;
|
||||
};
|
||||
onParamsChange?: (params: {
|
||||
monthlyAmount: number;
|
||||
years: number;
|
||||
allocations: Record<string, number>;
|
||||
}) => 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<number>(initialParams?.monthlyAmount || 1000);
|
||||
const [years, setYears] = useState<number>(initialParams?.years || 5); // Default projection for 5 years
|
||||
const [allocations, setAllocations] = useState<Record<string, number>>(
|
||||
initialParams?.allocations ||
|
||||
stocks.reduce((acc, stock) => {
|
||||
acc[stock.id] = 100 / stocks.length; // Equal distribution by default
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
);
|
||||
|
||||
// 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<any>(null);
|
||||
const [simulationParams, setSimulationParams] = useState<{
|
||||
monthlyAmount: number;
|
||||
years: number;
|
||||
allocations: Record<string, number>;
|
||||
} | 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<string, number>);
|
||||
|
||||
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<string, number>);
|
||||
|
||||
// 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<string, number>, projectionYears: number) => {
|
||||
// Calculate expected annual return based on historical performance
|
||||
let totalWeight = 0;
|
||||
let weightedReturn = 0;
|
||||
|
||||
const stockReturns: Record<string, number> = {};
|
||||
|
||||
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<string, number> = {};
|
||||
|
||||
// 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 (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-6 mb-6 dark:border dark:border-slate-700">
|
||||
<h2 className="text-xl font-semibold mb-4 dark:text-gray-200">Savings Plan Simulator</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-2 gap-12">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Monthly Investment Amount
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={totalAmount}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="ml-2 dark:text-gray-400">€</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Projection Years
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={years}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-md font-semibold mb-2 dark:text-gray-300">Allocation Percentages</h3>
|
||||
<div className="grid grid-cols-8 gap-4">
|
||||
<div className="flex flex-wrap gap-2 overflow-y-auto pr-2 col-span-7">
|
||||
{stocks.map(stock => (
|
||||
<div key={stock.id} className="flex items-center border border-gray-200 dark:border-slate-700 rounded-md p-2 bg-gray-100 dark:bg-slate-700/50">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: stockColors[stock.id] }}
|
||||
></div>
|
||||
<span className="text-sm dark:text-gray-300 mr-2 truncate flex-1">{stock.name}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={allocations[stock.id] || 0}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="ml-1 dark:text-gray-400">%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className={`text-sm ${totalAllocation === 100 ? 'text-green-500' : 'text-red-500'}`}>
|
||||
Total Allocation: {totalAllocation.toFixed(1)}%
|
||||
</div>
|
||||
<button
|
||||
onClick={normalizeAllocations}
|
||||
disabled={totalAllocation === 100}
|
||||
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Normalize to 100%
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="runSimulationButton"
|
||||
onClick={runSimulation}
|
||||
disabled={stocks.length === 0}
|
||||
className="w-full py-2 bg-green-500 text-white rounded hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Run Simulation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{simulationResults && simulationParams && (
|
||||
<div className="mt-6">
|
||||
{/* Modified Information Boxes - Now 5 boxes in total */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4 mb-6">
|
||||
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||
<h4 className="font-semibold text-lg dark:text-white">
|
||||
Avg. Yearly Return
|
||||
</h4>
|
||||
<p className="text-2xl font-bold dark:text-white" style={{
|
||||
color: simulationResults.expectedAnnualReturn >= 0 ?
|
||||
'var(--color-success, #10b981)' :
|
||||
'var(--color-danger, #ef4444)'
|
||||
}}>
|
||||
{simulationResults.expectedAnnualReturn.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||
<h4 className="font-semibold text-lg dark:text-white">
|
||||
Monthly Investment
|
||||
</h4>
|
||||
<p className="text-2xl font-bold dark:text-white">
|
||||
€{simulationParams.monthlyAmount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||
<h4 className="font-semibold text-lg dark:text-white">
|
||||
Total Invested <span className="text-sm text-gray-500 dark:text-gray-400">({simulationParams.years} years)</span>
|
||||
</h4>
|
||||
<p className="text-2xl font-bold dark:text-white">
|
||||
€{(simulationParams.monthlyAmount * simulationParams.years * 12).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||
<h4 className="font-semibold text-lg dark:text-white">
|
||||
Projected Portfolio Value
|
||||
</h4>
|
||||
<p className="text-2xl font-bold dark:text-white">
|
||||
€{Math.round(simulationResults.portfolioValue).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-100 dark:bg-slate-700 rounded shadow">
|
||||
<h4 className="font-semibold text-lg dark:text-white">
|
||||
Total Gain
|
||||
</h4>
|
||||
<p className="text-2xl font-bold dark:text-white">
|
||||
€{Math.round(simulationResults.portfolioValue - (simulationParams.monthlyAmount * simulationParams.years * 12)).toLocaleString()}
|
||||
</p>
|
||||
<p className="dark:text-white">
|
||||
{(((simulationResults.portfolioValue / (simulationParams.monthlyAmount * simulationParams.years * 12)) - 1) * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-Width Chart */}
|
||||
<div className="w-full h-[300px] mt-6">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={simulationResults.projectionData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(date) => format(new Date(date), 'MMM yyyy')}
|
||||
tick={{ fill: '#4E4E4E' }}
|
||||
/>
|
||||
<YAxis tick={{ fill: '#4E4E4E' }} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`€${Math.round(value).toLocaleString()}`, 'Value']}
|
||||
labelFormatter={(label) => label}
|
||||
/>
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="portfolioValue"
|
||||
name="Portfolio Value"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="totalInvestment"
|
||||
name="Total Invested"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
97
src/components/SortableTable.tsx
Normal file
97
src/components/SortableTable.tsx
Normal file
|
@ -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 (
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-slate-700">
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`p-2 text-left dark:text-gray-200 ${column.sortable !== false ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-slate-600' : ''}`}
|
||||
onClick={() => column.sortable !== false && requestSort(column.key)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{column.label}
|
||||
{sortConfig?.key === column.key && (
|
||||
<span className="ml-1">
|
||||
{sortConfig.direction === 'ascending' ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.map((row, index) => (
|
||||
<tr key={index} className="border-b dark:border-slate-600">
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="p-2 dark:text-gray-200">
|
||||
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
86
src/hooks/useLivePrice.ts
Normal file
86
src/hooks/useLivePrice.ts
Normal file
|
@ -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<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastPrice, setLastPrice] = useState<number | null>(null);
|
||||
const [usedCurrency, setUsedCurrency] = useState<string | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const timerRef = useRef<number | null>(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
|
||||
};
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -25,8 +25,6 @@ export const EQUITY_TYPES = {
|
|||
export const searchAssets = async (query: string, equityType: string): Promise<Asset[]> => {
|
||||
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<A
|
|||
});
|
||||
|
||||
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||
console.log(`Request URL: ${url}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
|
@ -44,7 +41,6 @@ export const searchAssets = async (query: string, equityType: string): Promise<A
|
|||
}
|
||||
|
||||
const data = await response.json() as YahooSearchResponse;
|
||||
console.log("API response:", data);
|
||||
|
||||
if (data.finance.error) {
|
||||
console.error(`API error: ${data.finance.error}`);
|
||||
|
@ -52,7 +48,6 @@ export const searchAssets = async (query: string, equityType: string): Promise<A
|
|||
}
|
||||
|
||||
if (!data.finance.result?.[0]?.documents) {
|
||||
console.log("No results found");
|
||||
return [];
|
||||
}
|
||||
|
||||
|
@ -61,9 +56,6 @@ export const searchAssets = async (query: string, equityType: string): Promise<A
|
|||
return data.finance.result[0].documents
|
||||
.filter(quote => {
|
||||
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);
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = [
|
||||
|
|
Loading…
Add table
Reference in a new issue