diff --git a/package-lock.json b/package-lock.json index 8928217..f6632b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "lucide-react": "^0.469.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hot-toast": "^2.4.1", "react-router-dom": "^7.1.0", "recharts": "^2.15.0", "use-debounce": "^10.0.4" @@ -2861,6 +2862,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz", + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -3735,6 +3745,22 @@ "react": "^19.0.0" } }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "license": "MIT", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", diff --git a/package.json b/package.json index e1905e7..c35ea0b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "investment-portfolio-tracker", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", @@ -16,6 +16,7 @@ "lucide-react": "^0.469.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hot-toast": "^2.4.1", "react-router-dom": "^7.1.0", "recharts": "^2.15.0", "use-debounce": "^10.0.4" diff --git a/src/App.tsx b/src/App.tsx index 1886c6f..2c124ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useState } from "react"; +import { Toaster } from "react-hot-toast"; import { AppShell } from "./components/Landing/AppShell"; import { LoadingPlaceholder } from "./components/utils/LoadingPlaceholder"; @@ -19,6 +20,7 @@ export default function App() { /> + ); } diff --git a/src/components/InvestmentForm.tsx b/src/components/InvestmentForm.tsx index ebcfb84..d3c503a 100644 --- a/src/components/InvestmentForm.tsx +++ b/src/components/InvestmentForm.tsx @@ -1,5 +1,6 @@ import { Loader2 } from "lucide-react"; import React, { useState } from "react"; +import toast from "react-hot-toast"; import { usePortfolioSelector } from "../hooks/usePortfolio"; import { generatePeriodicInvestments } from "../utils/calculations/assetValue"; @@ -81,9 +82,16 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + e.stopPropagation(); + + console.log("submitting") + console.time('generatePeriodicInvestments'); + console.timeLog('generatePeriodicInvestments', "1"); + setIsSubmitting(true); setTimeout(async () => { + console.log("timeout") try { if (type === "single") { const investment = { @@ -94,6 +102,7 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea date }; addInvestment(assetId, investment); + toast.success('Investment added successfully'); } else { const periodicSettings = { startDate: date, @@ -108,18 +117,23 @@ const InvestmentForm = ({ assetId, clearSelectedAsset }: { assetId: string, clea }, } : undefined), }; + console.timeLog('generatePeriodicInvestments', "2"); const investments = generatePeriodicInvestments( periodicSettings, dateRange.endDate, assetId ); + console.timeLog('generatePeriodicInvestments', "3"); + addInvestment(assetId, investments); - for (const investment of investments) { - addInvestment(assetId, investment); - } + toast.success('Periodic investment plan created successfully'); } + } catch (error:any) { + toast.error('Failed to add investment. Please try again.' + String(error?.message || error)); } finally { + console.timeLog('generatePeriodicInvestments', "4"); + console.timeEnd('generatePeriodicInvestments'); setIsSubmitting(false); setAmount(''); clearSelectedAsset(); diff --git a/src/components/Modals/AddAssetModal.tsx b/src/components/Modals/AddAssetModal.tsx index 6b2eaa3..53140c7 100644 --- a/src/components/Modals/AddAssetModal.tsx +++ b/src/components/Modals/AddAssetModal.tsx @@ -1,5 +1,6 @@ import { Loader2, Search, X } from "lucide-react"; import { useState } from "react"; +import toast from "react-hot-toast"; import { useDebouncedCallback } from "use-debounce"; import { usePortfolioSelector } from "../../hooks/usePortfolio"; @@ -43,17 +44,23 @@ export default function AddAssetModal({ onClose }: { onClose: () => void }) { dateRange.endDate ); + if (historicalData.length === 0) { + toast.error(`No historical data available for ${asset.name}`); + return; + } + const assetWithHistory = { ...asset, - // override name with the fetched long Name if available name: longName || asset.name, historicalData, }; addAsset(assetWithHistory); + toast.success(`Successfully added ${assetWithHistory.name}`); onClose(); } catch (error) { console.error('Error fetching historical data:', error); + toast.error(`Failed to add ${asset.name}. Please try again.`); } finally { setLoading(null); } diff --git a/src/components/Modals/EditInvestmentModal.tsx b/src/components/Modals/EditInvestmentModal.tsx index 1727a6b..a871f99 100644 --- a/src/components/Modals/EditInvestmentModal.tsx +++ b/src/components/Modals/EditInvestmentModal.tsx @@ -1,5 +1,6 @@ import { X } from "lucide-react"; import { useState } from "react"; +import toast from "react-hot-toast"; import { usePortfolioSelector } from "../../hooks/usePortfolio"; import { Investment } from "../../types"; @@ -18,11 +19,16 @@ export const EditInvestmentModal = ({ investment, assetId, onClose }: EditInvest const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - updateInvestment(assetId, investment.id, { - ...investment, - amount: parseFloat(amount), - }); - onClose(); + try { + updateInvestment(assetId, investment.id, { + ...investment, + amount: parseFloat(amount), + }); + toast.success('Investment updated successfully'); + onClose(); + } catch (error:any) { + toast.error('Failed to update investment' + String(error?.message || error)); + } }; return ( diff --git a/src/components/Modals/EditSavingsPlanModal.tsx b/src/components/Modals/EditSavingsPlanModal.tsx new file mode 100644 index 0000000..3816f41 --- /dev/null +++ b/src/components/Modals/EditSavingsPlanModal.tsx @@ -0,0 +1,236 @@ +import { Loader2, X } from "lucide-react"; +import { useState } from "react"; +import toast from "react-hot-toast"; + +import { usePortfolioSelector } from "../../hooks/usePortfolio"; +import { generatePeriodicInvestments } from "../../utils/calculations/assetValue"; + +interface EditSavingsPlanModalProps { + assetId: string; + groupId: string; + amount: number; + dayOfMonth: number; + interval: number; + dynamic?: { + type: 'percentage' | 'fixed'; + value: number; + yearInterval: number; + }; + onClose: () => void; +} + +export const EditSavingsPlanModal = ({ + assetId, + groupId, + amount: initialAmount, + dayOfMonth: initialDayOfMonth, + interval: initialInterval, + dynamic: initialDynamic, + onClose +}: EditSavingsPlanModalProps) => { + const [amount, setAmount] = useState(initialAmount.toString()); + const [dayOfMonth, setDayOfMonth] = useState(initialDayOfMonth.toString()); + const [interval, setInterval] = useState(initialInterval.toString()); + const [isDynamic, setIsDynamic] = useState(!!initialDynamic); + const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>(initialDynamic?.type || 'percentage'); + const [dynamicValue, setDynamicValue] = useState(initialDynamic?.value.toString() || ''); + const [yearInterval, setYearInterval] = useState(initialDynamic?.yearInterval.toString() || '1'); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { dateRange, addInvestment, removeInvestment, assets } = usePortfolioSelector((state) => ({ + dateRange: state.dateRange, + addInvestment: state.addInvestment, + removeInvestment: state.removeInvestment, + assets: state.assets, + })); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsSubmitting(true); + + setTimeout(async () => { + try { + // First, remove all existing investments for this savings plan + const asset = assets.find(a => a.id === assetId)!; + const investments = asset.investments.filter(inv => inv.periodicGroupId === groupId); + const startDate = investments[0].date!; // Keep original start date + + investments.forEach(inv => { + removeInvestment(assetId, inv.id); + }); + + // Generate and add new investments + const periodicSettings = { + startDate, + dayOfMonth: parseInt(dayOfMonth), + interval: parseInt(interval), + amount: parseFloat(amount), + ...(isDynamic ? { + dynamic: { + type: dynamicType, + value: parseFloat(dynamicValue), + yearInterval: parseInt(yearInterval), + }, + } : undefined), + }; + + const newInvestments = generatePeriodicInvestments( + periodicSettings, + dateRange.endDate, + assetId + ); + + addInvestment(assetId, newInvestments); + toast.success('Savings plan updated successfully'); + onClose(); + } catch (error:any) { + toast.error('Failed to update savings plan: ' + String(error?.message || error)); + } finally { + setIsSubmitting(false); + } + }, 10); + }; + + return ( +
+
+
+

Edit Savings Plan

+ +
+ +
+
+ + setAmount(e.target.value)} + className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300" + step="0.01" + min="0" + required + /> +
+ +
+ + setDayOfMonth(e.target.value)} + className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300" + min="1" + max="31" + required + /> +
+ +
+ + setInterval(e.target.value)} + className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300" + min="1" + required + /> +
+ +
+ +
+ + {isDynamic && ( + <> +
+ + +
+ +
+ + setDynamicValue(e.target.value)} + className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300" + min="0" + step={dynamicType === 'percentage' ? '0.1' : '1'} + required + /> +
+ +
+ + setYearInterval(e.target.value)} + className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300" + min="1" + required + /> +
+ + )} + +
+ + +
+
+
+
+ ); +}; diff --git a/src/components/PortfolioTable.tsx b/src/components/PortfolioTable.tsx index e2e312a..9d01de6 100644 --- a/src/components/PortfolioTable.tsx +++ b/src/components/PortfolioTable.tsx @@ -3,12 +3,14 @@ import { Download, FileDown, LineChart, Loader2, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; +import toast from "react-hot-toast"; import { usePortfolioSelector } from "../hooks/usePortfolio"; import { Investment } from "../types"; import { calculateInvestmentPerformance } from "../utils/calculations/performance"; import { downloadTableAsCSV, generatePortfolioPDF } from "../utils/export"; import { EditInvestmentModal } from "./Modals/EditInvestmentModal"; +import { EditSavingsPlanModal } from "./Modals/EditSavingsPlanModal"; import { FutureProjectionModal } from "./Modals/FutureProjectionModal"; import { Tooltip } from "./utils/ToolTip"; @@ -23,8 +25,20 @@ export default function PortfolioTable() { investment: Investment; assetId: string; } | null>(null); - const [showSavingsPlans, setShowSavingsPlans] = useState(true); const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); + const [isUpdatingSavingsPlan, setIsUpdatingSavingsPlan] = useState(false); + const [editingSavingsPlan, setEditingSavingsPlan] = useState<{ + assetId: string; + groupId: string; + amount: number; + dayOfMonth: number; + interval: number; + dynamic?: { + type: 'percentage' | 'fixed'; + value: number; + yearInterval: number; + }; + } | null>(null); const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]); @@ -34,13 +48,23 @@ export default function PortfolioTable() { const handleDelete = useCallback((investmentId: string, assetId: string) => { if (window.confirm("Are you sure you want to delete this investment?")) { - removeInvestment(assetId, investmentId); + try { + removeInvestment(assetId, investmentId); + toast.success('Investment deleted successfully'); + } catch (error:any) { + toast.error('Failed to delete investment' + String(error?.message || error)); + } } }, [removeInvestment]); const handleClearAll = useCallback(() => { if (window.confirm("Are you sure you want to clear all investments?")) { - clearInvestments(); + try { + clearInvestments(); + toast.success('All investments cleared successfully'); + } catch (error:any) { + toast.error('Failed to clear investments' + String(error?.message || error)); + } } }, [clearInvestments]); @@ -113,11 +137,39 @@ export default function PortfolioTable() { savingsPlansPerformance, performance.summary.performancePerAnnoPerformance ); + toast.success('PDF generated successfully'); + } catch (error:any) { + toast.error('Failed to generate PDF' + String(error?.message || error)); } finally { setIsGeneratingPDF(false); } }; + const handleDeleteSavingsPlan = useCallback((assetId: string, groupId: string) => { + if (window.confirm("Are you sure you want to delete this savings plan? All related investments will be removed.")) { + try { + setIsUpdatingSavingsPlan(true); + setTimeout(() => { + try { + const asset = assets.find(a => a.id === assetId); + if (!asset) throw new Error('Asset not found'); + const investments = asset.investments.filter(inv => inv.periodicGroupId === groupId); + investments.forEach(inv => { + removeInvestment(assetId, inv.id); + }); + toast.success('Savings plan deleted successfully'); + } catch (error:any) { + toast.error('Failed to delete savings plan: ' + String(error?.message || error)); + } finally { + setIsUpdatingSavingsPlan(false); + } + }, 10); + } catch (error:any) { + toast.error('Failed to delete savings plan: ' + String(error?.message || error)); + } + } + }, [assets, removeInvestment]); + return (
@@ -140,15 +192,6 @@ export default function PortfolioTable() { Future Projection - -
- {!isSavingsPlanOverviewDisabled && showSavingsPlans && savingsPlansPerformance.length > 0 && ( + {!isSavingsPlanOverviewDisabled && savingsPlansPerformance.length > 0 && (

Savings Plans Performance

@@ -184,20 +227,57 @@ export default function PortfolioTable() { Total Invested Current Value Performance (%) - Performance (p.a.) + Performance (p.a.) + Actions - {savingsPlansPerformance.map((plan) => ( - - {plan.assetName} - {plan.amount} - €{plan.totalInvested.toFixed(2)} - €{plan.currentValue.toFixed(2)} - {plan.performancePercentage.toFixed(2)}% - {plan.performancePerAnnoPerformance.toFixed(2)}% - - ))} + {savingsPlansPerformance.map((plan) => { + const asset = assets.find(a => a.name === plan.assetName)!; + const firstInvestment = asset.investments.find(inv => inv.type === 'periodic')!; + const groupId = firstInvestment.periodicGroupId!; + + return ( + + {plan.assetName} + {plan.amount} + €{plan.totalInvested.toFixed(2)} + €{plan.currentValue.toFixed(2)} + {plan.performancePercentage.toFixed(2)}% + {plan.performancePerAnnoPerformance.toFixed(2)}% + +
+ + +
+ + + ); + })}
@@ -276,10 +356,10 @@ export default function PortfolioTable() { {performance.summary.performancePercentage.toFixed(2)}%
    -
  • (avg. acc. {averagePerformance}%)
  • -
  • (avg. p.a. {performance.summary.performancePerAnnoPerformance.toFixed(2)}%)
  • -
  • (best p.a. {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})
  • -
  • (worst p.a. {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})
  • +
  • (avg. acc. {averagePerformance}%)
  • +
  • (avg. p.a. {performance.summary.performancePerAnnoPerformance.toFixed(2)}%)
  • +
  • (best p.a. {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})
  • +
  • (worst p.a. {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})
@@ -366,6 +446,12 @@ export default function PortfolioTable() { onClose={() => setShowProjection(false)} /> )} + {editingSavingsPlan && ( + setEditingSavingsPlan(null)} + /> + )}
); }; diff --git a/src/providers/PortfolioProvider.tsx b/src/providers/PortfolioProvider.tsx index db88119..9bb69a3 100644 --- a/src/providers/PortfolioProvider.tsx +++ b/src/providers/PortfolioProvider.tsx @@ -16,7 +16,7 @@ type PortfolioAction = | { type: 'ADD_ASSET'; payload: Asset } | { type: 'REMOVE_ASSET'; payload: string } | { type: 'CLEAR_ASSETS' } - | { type: 'ADD_INVESTMENT'; payload: { assetId: string; investment: Investment } } + | { type: 'ADD_INVESTMENT'; payload: { assetId: string; investment: Investment | Investment[] } } | { type: 'REMOVE_INVESTMENT'; payload: { assetId: string; investmentId: string } } | { type: 'UPDATE_DATE_RANGE'; payload: DateRange } | { type: 'UPDATE_ASSET_HISTORICAL_DATA'; payload: { assetId: string; historicalData: HistoricalData[]; longName?: string } } @@ -57,7 +57,7 @@ const portfolioReducer = (state: PortfolioState, action: PortfolioAction): Portf ...state, assets: state.assets.map(asset => asset.id === action.payload.assetId - ? { ...asset, investments: [...asset.investments, action.payload.investment] } + ? { ...asset, investments: [...asset.investments, ...(Array.isArray(action.payload.investment) ? action.payload.investment : [action.payload.investment])] } : asset ) }; @@ -127,7 +127,7 @@ export interface PortfolioContextType extends PortfolioState { addAsset: (asset: Asset) => void; removeAsset: (assetId: string) => void; clearAssets: () => void; - addInvestment: (assetId: string, investment: Investment) => void; + addInvestment: (assetId: string, investment: Investment | Investment[]) => void; removeInvestment: (assetId: string, investmentId: string) => void; updateDateRange: (dateRange: DateRange) => void; updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[], longName?: string) => void; @@ -148,7 +148,7 @@ export const PortfolioProvider = ({ children }: { children: React.ReactNode }) = addAsset: (asset: Asset) => dispatch({ type: 'ADD_ASSET', payload: asset }), removeAsset: (assetId: string) => dispatch({ type: 'REMOVE_ASSET', payload: assetId }), clearAssets: () => dispatch({ type: 'CLEAR_ASSETS' }), - addInvestment: (assetId: string, investment: Investment) => + addInvestment: (assetId: string, investment: Investment | Investment[]) => dispatch({ type: 'ADD_INVESTMENT', payload: { assetId, investment } }), removeInvestment: (assetId: string, investmentId: string) => dispatch({ type: 'REMOVE_INVESTMENT', payload: { assetId, investmentId } }), diff --git a/src/services/yahooFinanceService.ts b/src/services/yahooFinanceService.ts index 515e3f7..a0b107f 100644 --- a/src/services/yahooFinanceService.ts +++ b/src/services/yahooFinanceService.ts @@ -1,4 +1,5 @@ import type { Asset, YahooSearchResponse, YahooChartResult } from "../types"; +import toast from "react-hot-toast"; // this is only needed when hosted staticly without a proxy server or smt // TODO change it to use the proxy server @@ -49,6 +50,7 @@ export const searchAssets = async (query: string): Promise => { })); } catch (error) { console.error('Error searching assets:', error); + toast.error('Failed to search assets. Please try again later.'); return []; } }; @@ -81,6 +83,7 @@ export const getHistoricalData = async (symbol: string, startDate: string, endDa } } catch (error) { console.error('Error fetching historical data:', error); + toast.error(`Failed to fetch historical data for ${symbol}. Please try again later.`); return { historicalData: [], longName: '' }; } }; diff --git a/src/utils/calculations/performance.ts b/src/utils/calculations/performance.ts index f436c90..cd2e04b 100644 --- a/src/utils/calculations/performance.ts +++ b/src/utils/calculations/performance.ts @@ -52,7 +52,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor const yearStart = new Date(year, 0, 1); // 1. Januar const yearEnd = year === endYear ? new Date(year, now.getMonth(), now.getDate()) : new Date(year, 11, 31); // Aktuelles Datum oder 31. Dez. - const investmentsPerformances:number[] = []; + const yearInvestments: { percent: number; weight: number }[] = []; for (const asset of assets) { // Get prices for the start and end of the year @@ -67,7 +67,7 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor if (startPrice === 0 || endPrice === 0) { console.warn(`Skipping asset for year ${year} due to missing start or end price`); - continue; // Überspringe, wenn keine Daten vorhanden + continue; } // Get all investments made before or during this year @@ -88,19 +88,23 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor (data) => isAfter(new Date(data.date), new Date(investment.date!)) ).find((v) => v.price !== 0)?.price || 0; - if (buyInPrice > 0) { - const shares = investment.amount / buyInPrice; // Berechne Anzahl der Shares + const shares = investment.amount / buyInPrice; const endValue = shares * endPrice; const startValue = shares * startPrice; - investmentsPerformances.push((endValue - startValue) / startValue * 100); + yearInvestments.push({ + percent: ((endValue - startValue) / startValue) * 100, + weight: startValue + }); } } } - // Calculate performance for the year - if (investmentsPerformances.length > 0) { - const percentage = investmentsPerformances.reduce((acc, curr) => acc + curr, 0) / investmentsPerformances.length; + // Calculate weighted average performance for the year + if (yearInvestments.length > 0) { + const totalWeight = yearInvestments.reduce((sum, inv) => sum + inv.weight, 0); + const percentage = yearInvestments.reduce((sum, inv) => + sum + (inv.percent * (inv.weight / totalWeight)), 0); if (!isNaN(percentage)) { annualPerformances.push({ year, percentage }); diff --git a/src/utils/calculations/portfolioValue.ts b/src/utils/calculations/portfolioValue.ts index 29fbf08..b7f0f52 100644 --- a/src/utils/calculations/portfolioValue.ts +++ b/src/utils/calculations/portfolioValue.ts @@ -21,8 +21,12 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) = percentageChange: 0, assets: {}, }; - // this should contain the percentage gain of all investments till now - const pPercents: number[] = []; + + interface WeightedPercent { + percent: number; + weight: number; + } + const weightedPercents: WeightedPercent[] = []; for (const asset of assets) { // calculate the invested kapital @@ -49,14 +53,20 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) = dayData.assets[asset.id] = currentValueOfAsset; const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100; - if (!Number.isNaN(percent)) pPercents.push(percent); + if (!Number.isNaN(percent) && investedValue && investedValue > 0) { + weightedPercents.push({ + percent, + weight: investedValue + }); + } } } - - // Calculate average percentage change if percentages array is not empty - if (pPercents.length > 0) { - dayData.percentageChange = pPercents.reduce((a, b) => a + b, 0) / pPercents.length; + // Calculate weighted average percentage change + if (weightedPercents.length > 0) { + const totalWeight = weightedPercents.reduce((sum, wp) => sum + wp.weight, 0); + dayData.percentageChange = weightedPercents.reduce((sum, wp) => + sum + (wp.percent * (wp.weight / totalWeight)), 0); } else { dayData.percentageChange = 0; }