v1.1.0 - ability to edit savings plan, correct %performance calculation

This commit is contained in:
tomato6966 2024-12-23 18:28:37 +01:00
parent d8ad384205
commit a5d014ec4d
12 changed files with 452 additions and 57 deletions

26
package-lock.json generated
View file

@ -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",

View file

@ -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"

View file

@ -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() {
/>
</Suspense>
</AppShell>
<Toaster position="bottom-right" />
</PortfolioProvider>
);
}

View file

@ -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();

View file

@ -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);
}

View file

@ -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 (

View file

@ -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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold dark:text-gray-200">Edit Savings Plan</h2>
<button onClick={onClose} className="p-2">
<X className="w-6 h-6 dark:text-gray-200" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
Investment Amount
</label>
<input
type="number"
value={amount}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
Day of Month
</label>
<input
type="number"
value={dayOfMonth}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
Interval (days)
</label>
<input
type="number"
value={interval}
onChange={(e) => 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
/>
</div>
<div>
<label className="flex items-center gap-2 dark:text-gray-200">
<input
type="checkbox"
checked={isDynamic}
onChange={(e) => setIsDynamic(e.target.checked)}
className="rounded"
/>
<span className="text-sm font-medium">Dynamic Investment Growth</span>
</label>
</div>
{isDynamic && (
<>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
Growth Type
</label>
<select
value={dynamicType}
onChange={(e) => setDynamicType(e.target.value as 'percentage' | 'fixed')}
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
>
<option value="percentage">Percentage</option>
<option value="fixed">Fixed Amount</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
Increase Value
</label>
<input
type="number"
value={dynamicValue}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
Year Interval for Increase
</label>
<input
type="number"
value={yearInterval}
onChange={(e) => 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
/>
</div>
</>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSubmitting ? (
<>
<Loader2 className="animate-spin" size={16} />
Updating...
</>
) : (
'Update Plan'
)}
</button>
</div>
</form>
</div>
</div>
);
};

View file

@ -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 (
<div className="space-y-4">
<div className="overflow-x-auto min-h-[500px] dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60">
@ -140,15 +192,6 @@ export default function PortfolioTable() {
Future Projection
</button>
<button
onClick={() => setShowSavingsPlans(prev => !prev)}
disabled={isSavingsPlanOverviewDisabled}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 flex items-center gap-2 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw size={16} />
{showSavingsPlans ? 'Hide' : 'Show'} Savings Plans Performance
</button>
<button
onClick={handleGeneratePDF}
className="px-4 py-2 bg-purple-500 text-white rounded hover:bg-purple-600 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
@ -164,7 +207,7 @@ export default function PortfolioTable() {
</div>
</div>
{!isSavingsPlanOverviewDisabled && showSavingsPlans && savingsPlansPerformance.length > 0 && (
{!isSavingsPlanOverviewDisabled && savingsPlansPerformance.length > 0 && (
<div className="overflow-x-auto mb-4 dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold">Savings Plans Performance</h3>
@ -184,20 +227,57 @@ export default function PortfolioTable() {
<th className="px-4 py-2">Total Invested</th>
<th className="px-4 py-2">Current Value</th>
<th className="px-4 py-2">Performance (%)</th>
<th className="px-4 py-2 last:rounded-tr-lg">Performance (p.a.)</th>
<th className="px-4 py-2">Performance (p.a.)</th>
<th className="px-4 py-2 last:rounded-tr-lg">Actions</th>
</tr>
</thead>
<tbody>
{savingsPlansPerformance.map((plan) => (
<tr key={plan.assetName} className="border-t border-gray-200 dark:border-slate-600">
<td className="px-4 py-2">{plan.assetName}</td>
<td className="px-4 py-2">{plan.amount}</td>
<td className="px-4 py-2">{plan.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{plan.currentValue.toFixed(2)}</td>
<td className="px-4 py-2">{plan.performancePercentage.toFixed(2)}%</td>
<td className="px-4 py-2">{plan.performancePerAnnoPerformance.toFixed(2)}%</td>
</tr>
))}
{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 (
<tr key={plan.assetName} className="border-t border-gray-200 dark:border-slate-600">
<td className="px-4 py-2">{plan.assetName}</td>
<td className="px-4 py-2">{plan.amount}</td>
<td className="px-4 py-2">{plan.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{plan.currentValue.toFixed(2)}</td>
<td className="px-4 py-2">{plan.performancePercentage.toFixed(2)}%</td>
<td className="px-4 py-2">{plan.performancePerAnnoPerformance.toFixed(2)}%</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<button
onClick={() => setEditingSavingsPlan({
assetId: asset.id,
groupId,
amount: firstInvestment.amount,
dayOfMonth: parseInt(firstInvestment.date!.split('-')[2]),
interval: 30, // You might want to store this in the investment object
// Add dynamic settings if available
})}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
>
{isUpdatingSavingsPlan || editingSavingsPlan ? (
<Loader2 className="animate-spin" size={16} />
) : (<Pencil className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleDeleteSavingsPlan(asset.id, groupId)}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
>
{isUpdatingSavingsPlan || editingSavingsPlan ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@ -276,10 +356,10 @@ export default function PortfolioTable() {
<td className="px-4 py-2">
{performance.summary.performancePercentage.toFixed(2)}%
<ul>
<li className="text-xs text-gray-500 dark:text-gray-400"> (avg. acc. {averagePerformance}%)</li>
<li className="text-xs text-gray-500 dark:text-gray-400"> (avg. p.a. {performance.summary.performancePerAnnoPerformance.toFixed(2)}%)</li>
<li className="text-xs text-gray-500 dark:text-gray-400"> (best p.a. {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})</li>
<li className="text-xs text-gray-500 dark:text-gray-400"> (worst p.a. {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})</li>
<li className="text-xs text-gray-500 dark:text-gray-400">(avg. acc. {averagePerformance}%)</li>
<li className="text-xs text-gray-500 dark:text-gray-400">(avg. p.a. {performance.summary.performancePerAnnoPerformance.toFixed(2)}%)</li>
<li className="text-[10px] text-gray-500 dark:text-gray-400 italic">(best p.a. {performance.summary.bestPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.bestPerformancePerAnno?.[0]?.year || "N/A"})</li>
<li className="text-[10px] text-gray-500 dark:text-gray-400 italic">(worst p.a. {performance.summary.worstPerformancePerAnno?.[0]?.percentage?.toFixed(2) || "0.00"}% {performance.summary.worstPerformancePerAnno?.[0]?.year || "N/A"})</li>
</ul>
</td>
<td className="px-4 py-2"></td>
@ -366,6 +446,12 @@ export default function PortfolioTable() {
onClose={() => setShowProjection(false)}
/>
)}
{editingSavingsPlan && (
<EditSavingsPlanModal
{...editingSavingsPlan}
onClose={() => setEditingSavingsPlan(null)}
/>
)}
</div>
);
};

View file

@ -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 } }),

View file

@ -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<Asset[]> => {
}));
} 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: '' };
}
};

View file

@ -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 });

View file

@ -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;
}