v1.2.0 new date management and intervals + slight fixes

This commit is contained in:
tomato6966 2024-12-24 12:39:59 +01:00
parent 4c641701eb
commit 0aa0425938
14 changed files with 285 additions and 174 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "investment-portfolio-tracker", "name": "investment-portfolio-tracker",
"private": true, "private": true,
"version": "1.1.1", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

@ -63,17 +63,25 @@ export default function InvestmentFormWrapper() {
); );
} }
interface IntervalConfig {
value: number;
unit: 'days' | 'months' | 'years';
}
const InvestmentForm = ({ assetId }: { assetId: string }) => { const InvestmentForm = ({ assetId }: { assetId: string }) => {
const [type, setType] = useState<'single' | 'periodic'>('single'); const [type, setType] = useState<'single' | 'periodic'>('single');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [date, setDate] = useState(''); const [date, setDate] = useState('');
const [dayOfMonth, setDayOfMonth] = useState('1'); const [dayOfMonth, setDayOfMonth] = useState('1');
const [interval, setInterval] = useState('30');
const [isDynamic, setIsDynamic] = useState(false); const [isDynamic, setIsDynamic] = useState(false);
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage'); const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
const [dynamicValue, setDynamicValue] = useState(''); const [dynamicValue, setDynamicValue] = useState('');
const [yearInterval, setYearInterval] = useState('1'); const [yearInterval, setYearInterval] = useState('1');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [intervalConfig, setIntervalConfig] = useState<IntervalConfig>({
value: 1,
unit: 'months'
});
const { dateRange, addInvestment } = usePortfolioSelector((state) => ({ const { dateRange, addInvestment } = usePortfolioSelector((state) => ({
dateRange: state.dateRange, dateRange: state.dateRange,
@ -84,14 +92,9 @@ const InvestmentForm = ({ assetId }: { assetId: string }) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
console.log("submitting")
console.time('generatePeriodicInvestments');
console.timeLog('generatePeriodicInvestments', "1");
setIsSubmitting(true); setIsSubmitting(true);
setTimeout(() => { setTimeout(() => {
console.log("timeout")
try { try {
if (type === "single") { if (type === "single") {
const investment = { const investment = {
@ -99,16 +102,17 @@ const InvestmentForm = ({ assetId }: { assetId: string }) => {
assetId, assetId,
type, type,
amount: parseFloat(amount), amount: parseFloat(amount),
date date: new Date(date),
}; };
addInvestment(assetId, investment); addInvestment(assetId, investment);
toast.success('Investment added successfully'); toast.success('Investment added successfully');
} else { } else {
const periodicSettings = { const periodicSettings = {
startDate: date, startDate: new Date(date),
dayOfMonth: parseInt(dayOfMonth), dayOfMonth: parseInt(dayOfMonth),
interval: parseInt(interval), interval: intervalConfig.value,
amount: parseFloat(amount), amount: parseFloat(amount),
intervalUnit: intervalConfig.unit,
...(isDynamic ? { ...(isDynamic ? {
dynamic: { dynamic: {
type: dynamicType, type: dynamicType,
@ -117,23 +121,19 @@ const InvestmentForm = ({ assetId }: { assetId: string }) => {
}, },
} : undefined), } : undefined),
}; };
console.timeLog('generatePeriodicInvestments', "2");
const investments = generatePeriodicInvestments( const investments = generatePeriodicInvestments(
periodicSettings, periodicSettings,
dateRange.endDate, new Date(dateRange.endDate),
assetId assetId
); );
console.timeLog('generatePeriodicInvestments', "3");
addInvestment(assetId, investments); addInvestment(assetId, investments);
toast.success('Periodic investment plan created successfully'); toast.success('Sparplan erfolgreich erstellt');
} }
} catch (error:any) { } catch (error:any) {
toast.error('Failed to add investment. Please try again.' + String(error?.message || error)); toast.error('Fehler beim Erstellen des Investments: ' + String(error?.message || error));
} finally { } finally {
console.timeLog('generatePeriodicInvestments', "4");
console.timeEnd('generatePeriodicInvestments');
setIsSubmitting(false); setIsSubmitting(false);
setAmount(''); setAmount('');
} }
@ -193,27 +193,51 @@ const InvestmentForm = ({ assetId }: { assetId: string }) => {
required required
/> />
</div> </div>
<label className="block text-sm font-medium mb-1">SavingsPlan-Start Date</label> <div>
<label className="block text-sm font-medium mb-1">
Interval
<span className="ml-1 text-gray-400 hover:text-gray-600 cursor-help" title="Wählen Sie das Intervall für Ihre regelmäßigen Investitionen. Bei monatlichen Zahlungen am 1. eines Monats werden die Investments automatisch am 1. jeden Monats ausgeführt.">
</span>
</label>
<div className="flex gap-2">
<input
type="number"
value={intervalConfig.value}
onChange={(e) => setIntervalConfig(prev => ({
...prev,
value: parseInt(e.target.value)
}))}
className="w-24 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
min="1"
required
/>
<select
value={intervalConfig.unit}
onChange={(e) => setIntervalConfig(prev => ({
...prev,
unit: e.target.value as IntervalConfig['unit']
}))}
className="flex-1 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="quarters">Quarters</option>
<option value="years">Years</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">SavingsPlan-Start Datum</label>
<input <input
type="date" type="date"
value={date} value={date}
// the "dayOf the month should not be change able, due to the day of the"
onChange={(e) => setDate(e.target.value)} onChange={(e) => setDate(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 [&::-webkit-calendar-picker-indicator]:dark:invert" className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
required required
/> lang="de"
<div>
<label className="block text-sm font-medium mb-1">
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="14"
max="365"
required
/> />
</div> </div>

View file

@ -3,6 +3,7 @@ import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { usePortfolioSelector } from "../../hooks/usePortfolio"; import { usePortfolioSelector } from "../../hooks/usePortfolio";
import { PeriodicSettings } from "../../types";
import { generatePeriodicInvestments } from "../../utils/calculations/assetValue"; import { generatePeriodicInvestments } from "../../utils/calculations/assetValue";
interface EditSavingsPlanModalProps { interface EditSavingsPlanModalProps {
@ -31,6 +32,7 @@ export const EditSavingsPlanModal = ({
const [amount, setAmount] = useState(initialAmount.toString()); const [amount, setAmount] = useState(initialAmount.toString());
const [dayOfMonth, setDayOfMonth] = useState(initialDayOfMonth.toString()); const [dayOfMonth, setDayOfMonth] = useState(initialDayOfMonth.toString());
const [interval, setInterval] = useState(initialInterval.toString()); const [interval, setInterval] = useState(initialInterval.toString());
const [intervalUnit, setIntervalUnit] = useState<'days' | 'weeks' | 'months' | 'quarters' | 'years'>('months');
const [isDynamic, setIsDynamic] = useState(!!initialDynamic); const [isDynamic, setIsDynamic] = useState(!!initialDynamic);
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>(initialDynamic?.type || 'percentage'); const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>(initialDynamic?.type || 'percentage');
const [dynamicValue, setDynamicValue] = useState(initialDynamic?.value.toString() || ''); const [dynamicValue, setDynamicValue] = useState(initialDynamic?.value.toString() || '');
@ -61,10 +63,11 @@ export const EditSavingsPlanModal = ({
}); });
// Generate and add new investments // Generate and add new investments
const periodicSettings = { const periodicSettings: PeriodicSettings = {
startDate, startDate: new Date(startDate),
dayOfMonth: parseInt(dayOfMonth), dayOfMonth: parseInt(dayOfMonth),
interval: parseInt(interval), interval: parseInt(interval),
intervalUnit: intervalUnit,
amount: parseFloat(amount), amount: parseFloat(amount),
...(isDynamic ? { ...(isDynamic ? {
dynamic: { dynamic: {
@ -134,17 +137,28 @@ export const EditSavingsPlanModal = ({
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1 dark:text-gray-200"> <label className="block text-sm font-medium mb-1 dark:text-gray-200">Interval</label>
Interval (days) <div className="flex gap-2">
</label>
<input <input
type="number" type="number"
value={interval} value={interval}
onChange={(e) => setInterval(e.target.value)} 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" className="w-24 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
min="1" min="1"
required required
/> />
<select
value={intervalUnit}
onChange={(e) => setIntervalUnit(e.target.value as 'days' | 'weeks' | 'months' | 'quarters' | 'years')}
className="flex-1 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="quarters">Quarters</option>
<option value="years">Years</option>
</select>
</div>
</div> </div>
<div> <div>

View file

@ -1,3 +1,4 @@
import { isSameDay } from "date-fns";
import { BarChart as BarChartIcon, LineChart as LineChartIcon, Loader2, X } from "lucide-react"; import { BarChart as BarChartIcon, LineChart as LineChartIcon, Loader2, X } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { import {
@ -9,7 +10,6 @@ import { calculateFutureProjection } from "../../utils/calculations/futureProjec
import { formatCurrency } from "../../utils/formatters"; import { formatCurrency } from "../../utils/formatters";
import type { ProjectionData, SustainabilityAnalysis, WithdrawalPlan } from "../../types"; import type { ProjectionData, SustainabilityAnalysis, WithdrawalPlan } from "../../types";
interface FutureProjectionModalProps { interface FutureProjectionModalProps {
performancePerAnno: number; performancePerAnno: number;
bestPerformancePerAnno: { percentage: number, year: number }[]; bestPerformancePerAnno: { percentage: number, year: number }[];
@ -39,7 +39,7 @@ export const FutureProjectionModal = ({
amount: 0, amount: 0,
interval: 'monthly', interval: 'monthly',
startTrigger: 'auto', startTrigger: 'auto',
startDate: new Date().toISOString().split('T')[0], startDate: new Date(),
startPortfolioValue: 0, startPortfolioValue: 0,
enabled: false, enabled: false,
autoStrategy: { autoStrategy: {
@ -65,8 +65,8 @@ export const FutureProjectionModal = ({
); );
setProjectionData(projection); setProjectionData(projection);
setSustainabilityAnalysis(sustainability); setSustainabilityAnalysis(sustainability);
const slicedBestCase = bestPerformancePerAnno.slice(0, Math.floor(bestPerformancePerAnno.length / 2)); const slicedBestCase = bestPerformancePerAnno.slice(0, bestPerformancePerAnno.length > 1 ? Math.floor(bestPerformancePerAnno.length / 2) : 1);
const slicedWorstCase = worstPerformancePerAnno.slice(0, Math.floor(worstPerformancePerAnno.length / 2)); const slicedWorstCase = worstPerformancePerAnno.slice(0, worstPerformancePerAnno.length > 1 ? Math.floor(worstPerformancePerAnno.length / 2) : 1);
const bestCase = slicedBestCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedBestCase.length || 0; const bestCase = slicedBestCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedBestCase.length || 0;
const worstCase = slicedWorstCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedWorstCase.length || 0; const worstCase = slicedWorstCase.reduce((acc, curr) => acc + curr.percentage, 0) / slicedWorstCase.length || 0;
@ -335,8 +335,8 @@ export const FutureProjectionModal = ({
// Create a merged and sorted dataset for consistent x-axis // Create a merged and sorted dataset for consistent x-axis
const mergedData = projectionData.map(basePoint => { const mergedData = projectionData.map(basePoint => {
const date = basePoint.date; const date = basePoint.date;
const bestPoint = scenarios.best.projection.find(p => p.date === date); const bestPoint = scenarios.best.projection.find(p => isSameDay(p.date, date));
const worstPoint = scenarios.worst.projection.find(p => p.date === date); const worstPoint = scenarios.worst.projection.find(p => isSameDay(p.date, date));
return { return {
date, date,
@ -549,10 +549,10 @@ export const FutureProjectionModal = ({
</label> </label>
<input <input
type="date" type="date"
value={withdrawalPlan.startDate} value={withdrawalPlan.startDate?.toISOString().split('T')[0]}
onChange={(e) => setWithdrawalPlan(prev => ({ onChange={(e) => setWithdrawalPlan(prev => ({
...prev, ...prev,
startDate: e.target.value startDate: new Date(e.target.value)
}))} }))}
min={new Date().toISOString().split('T')[0]} min={new Date().toISOString().split('T')[0]}
className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200" className="w-full p-2 border rounded dark:bg-slate-700 dark:border-slate-600 dark:text-gray-200"

View file

@ -1,5 +1,5 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { BarChart2, Eye, EyeOff, Maximize2, X } from "lucide-react"; import { BarChart2, Eye, EyeOff, Maximize2, RefreshCcw, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { import {
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
@ -27,7 +27,7 @@ export default function PortfolioChart() {
})); }));
const fetchHistoricalData = useCallback( const fetchHistoricalData = useCallback(
async (startDate: string, endDate: string) => { async (startDate: Date, endDate: Date) => {
for (const asset of assets) { for (const asset of assets) {
const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate); const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate);
updateAssetHistoricalData(asset.id, historicalData, longName); updateAssetHistoricalData(asset.id, historicalData, longName);
@ -66,14 +66,15 @@ export default function PortfolioChart() {
// Calculate percentage changes for each asset // Calculate percentage changes for each asset
const processedData = useMemo(() => data.map(point => { const processedData = useMemo(() => data.map(point => {
const processed: { [key: string]: number | string } = { const processed: { date: string, total: number, invested: number, percentageChange: number, ttwor: number, ttwor_percent: number, [key: string]: number | string } = {
date: point.date, date: format(point.date, 'yyyy-MM-dd'),
total: point.total, total: point.total,
invested: point.invested, invested: point.invested,
percentageChange: point.percentageChange, percentageChange: point.percentageChange,
ttwor: 0,
ttwor_percent: 0,
}; };
processed["ttwor"] = 0;
for (const asset of assets) { for (const asset of assets) {
const initialPrice = data[0].assets[asset.id]; const initialPrice = data[0].assets[asset.id];
const currentPrice = point.assets[asset.id]; const currentPrice = point.assets[asset.id];
@ -81,11 +82,11 @@ export default function PortfolioChart() {
processed[`${asset.id}_price`] = currentPrice; processed[`${asset.id}_price`] = currentPrice;
const percentDecimal = ((currentPrice - initialPrice) / initialPrice); const percentDecimal = ((currentPrice - initialPrice) / initialPrice);
processed[`${asset.id}_percent`] = percentDecimal * 100; processed[`${asset.id}_percent`] = percentDecimal * 100;
processed["ttwor"] += allAssetsInvestedKapitals[asset.id] + allAssetsInvestedKapitals[asset.id] * percentDecimal; processed.ttwor += allAssetsInvestedKapitals[asset.id] + allAssetsInvestedKapitals[asset.id] * percentDecimal;
} }
} }
processed["ttwor_percent"] = (processed["ttwor"] - Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0)) / Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0) * 100; processed.ttwor_percent = (processed.ttwor - Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0)) / Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0) * 100;
// add a processed["ttwor"] ttwor is what if you invested all of the kapital of all assets at the start of the period // add a processed["ttwor"] ttwor is what if you invested all of the kapital of all assets at the start of the period
@ -170,6 +171,12 @@ export default function PortfolioChart() {
debouncedFetchHistoricalData(newRange.startDate, newRange.endDate); debouncedFetchHistoricalData(newRange.startDate, newRange.endDate);
}, [updateDateRange, debouncedFetchHistoricalData]); }, [updateDateRange, debouncedFetchHistoricalData]);
const [renderKey, setRenderKey] = useState(0);
const handleReRender = useCallback(() => {
setRenderKey(prevKey => prevKey + 1);
}, []);
const ChartContent = useCallback(() => ( const ChartContent = useCallback(() => (
<> <>
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
@ -179,26 +186,34 @@ export default function PortfolioChart() {
onStartDateChange={(date) => handleUpdateDateRange({ ...dateRange, startDate: date })} onStartDateChange={(date) => handleUpdateDateRange({ ...dateRange, startDate: date })}
onEndDateChange={(date) => handleUpdateDateRange({ ...dateRange, endDate: date })} onEndDateChange={(date) => handleUpdateDateRange({ ...dateRange, endDate: date })}
/> />
<div className="flex items-center">
<button
onClick={handleReRender}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded ml-2 hover:text-blue-500"
>
<RefreshCcw className="w-5 h-5" />
</button>
<button <button
onClick={() => setIsFullscreen(!isFullscreen)} onClick={() => setIsFullscreen(!isFullscreen)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded" className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded hover:text-blue-500"
> >
<Maximize2 className="w-5 h-5" /> <Maximize2 className="w-5 h-5" />
</button> </button>
</div> </div>
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"}> </div>
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"} key={renderKey}>
<ResponsiveContainer> <ResponsiveContainer>
<LineChart data={processedData}> <LineChart data={processedData} className="p-3">
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" /> <CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
<XAxis <XAxis
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }} tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
dataKey="date" dataKey="date"
tickFormatter={(date) => format(new Date(date), 'MMM dd')} tickFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
/> />
<YAxis <YAxis
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }} tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
yAxisId="left" yAxisId="left"
tickFormatter={(value) => `${value.toLocaleString()}`} tickFormatter={(value) => `${value.toFixed(2)}`}
/> />
<YAxis <YAxis
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }} tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
@ -231,8 +246,9 @@ export default function PortfolioChart() {
return [`${value.toLocaleString()}€ (${((value - Number(assets[assetKey])) / Number(assets[assetKey]) * 100).toFixed(2)}%)`, name]; return [`${value.toLocaleString()}€ (${((value - Number(assets[assetKey])) / Number(assets[assetKey]) * 100).toFixed(2)}%)`, name];
}} }}
labelFormatter={(date) => format(new Date(date), 'MMM dd, yyyy')} labelFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
/> >
</Tooltip>
<Legend content={<CustomLegend />} /> <Legend content={<CustomLegend />} />
<Line <Line
type="monotone" type="monotone"
@ -292,16 +308,19 @@ export default function PortfolioChart() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<i className="text-xs text-gray-500"> <i className="text-xs text-gray-500">
Note: The YAxis on the left shows the value of your portfolio (black line) and invested capital (dotted line), *Note: The YAxis on the left shows the value of your portfolio (black line) and invested capital (dotted line),
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis. all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
</i> </i>
<p className="text-xs mt-2 text-gray-500 italic">
**Note: The % is based on daily weighted average data, thus the percentages might alter slightly.
</p>
</> </>
), [assets, isDarkMode, assetColors, handleUpdateDateRange, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, isFullscreen]); ), [assets, handleReRender, isDarkMode, assetColors, handleUpdateDateRange, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, isFullscreen, renderKey]);
if (isFullscreen) { if (isFullscreen) {
return ( return (
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 p-6"> <div className="fixed inset-0 bg-white dark:bg-slate-800 z-50">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Portfolio Chart</h2> <h2 className="text-xl font-bold">Portfolio Chart</h2>
<button <button

View file

@ -1,4 +1,4 @@
import { format } from "date-fns"; import { format, isBefore } from "date-fns";
import { import {
Download, FileDown, LineChart, Loader2, Pencil, RefreshCw, ShoppingBag, Trash2 Download, FileDown, LineChart, Loader2, Pencil, RefreshCw, ShoppingBag, Trash2
} from "lucide-react"; } from "lucide-react";
@ -306,7 +306,7 @@ export default function PortfolioTable() {
assetId: asset.id, assetId: asset.id,
groupId, groupId,
amount: firstInvestment.amount, amount: firstInvestment.amount,
dayOfMonth: parseInt(firstInvestment.date!.split('-')[2]), dayOfMonth: firstInvestment.date?.getDate() || 0,
interval: 30, // You might want to store this in the investment object interval: 30, // You might want to store this in the investment object
// Add dynamic settings if available // Add dynamic settings if available
})} })}
@ -432,7 +432,7 @@ export default function PortfolioTable() {
</tr> </tr>
</> </>
)} )}
{performance.investments.sort((a, b) => a.date.localeCompare(b.date)).map((inv, index) => { {performance.investments.sort((a, b) => isBefore(a.date, b.date) ? -1 : 1).map((inv, index) => {
const asset = assets.find(a => a.name === inv.assetName)!; const asset = assets.find(a => a.name === inv.assetName)!;
const investment = asset.investments.find(i => i.id === inv.id)! || inv; const investment = asset.investments.find(i => i.id === inv.id)! || inv;
const filtered = performance.investments.filter(v => v.assetName === inv.assetName); const filtered = performance.investments.filter(v => v.assetName === inv.assetName);

View file

@ -1,11 +1,12 @@
import { format, isValid, parseISO } from "date-fns";
import { useRef } from "react"; import { useRef } from "react";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
interface DateRangePickerProps { interface DateRangePickerProps {
startDate: string; startDate: Date;
endDate: string; endDate: Date;
onStartDateChange: (date: string) => void; onStartDateChange: (date: Date) => void;
onEndDateChange: (date: string) => void; onEndDateChange: (date: Date) => void;
} }
export const DateRangePicker = ({ export const DateRangePicker = ({
@ -17,25 +18,45 @@ export const DateRangePicker = ({
const startDateRef = useRef<HTMLInputElement>(null); const startDateRef = useRef<HTMLInputElement>(null);
const endDateRef = useRef<HTMLInputElement>(null); const endDateRef = useRef<HTMLInputElement>(null);
const formatDateToISO = (date: Date) => {
return format(date, 'yyyy-MM-dd');
};
const isValidDate = (dateString: string) => { const isValidDate = (dateString: string) => {
const date = new Date(dateString); const parsed = parseISO(dateString);
return date instanceof Date && !isNaN(date.getTime()) && dateString.length === 10; return isValid(parsed);
}; };
const debouncedStartDateChange = useDebouncedCallback( const debouncedStartDateChange = useDebouncedCallback(
(newDate: string) => { (dateString: string) => {
if (newDate !== startDate && isValidDate(newDate)) { if (isValidDate(dateString)) {
const newDate = new Date(Date.UTC(
parseISO(dateString).getUTCFullYear(),
parseISO(dateString).getUTCMonth(),
parseISO(dateString).getUTCDate()
));
if (newDate.getTime() !== startDate.getTime()) {
onStartDateChange(newDate); onStartDateChange(newDate);
} }
}
}, },
750 750
); );
const debouncedEndDateChange = useDebouncedCallback( const debouncedEndDateChange = useDebouncedCallback(
(newDate: string) => { (dateString: string) => {
if (newDate !== endDate && isValidDate(newDate)) { if (isValidDate(dateString)) {
const newDate = new Date(Date.UTC(
parseISO(dateString).getUTCFullYear(),
parseISO(dateString).getUTCMonth(),
parseISO(dateString).getUTCDate()
));
if (newDate.getTime() !== endDate.getTime()) {
onEndDateChange(newDate); onEndDateChange(newDate);
} }
}
}, },
750 750
); );
@ -59,10 +80,11 @@ export const DateRangePicker = ({
<input <input
ref={startDateRef} ref={startDateRef}
type="date" type="date"
defaultValue={startDate} defaultValue={formatDateToISO(startDate)}
onChange={handleStartDateChange} onChange={handleStartDateChange}
max={endDate} max={formatDateToISO(endDate)}
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert" className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
lang="de"
/> />
</div> </div>
<div> <div>
@ -70,11 +92,12 @@ export const DateRangePicker = ({
<input <input
ref={endDateRef} ref={endDateRef}
type="date" type="date"
defaultValue={endDate} defaultValue={formatDateToISO(endDate)}
onChange={handleEndDateChange} onChange={handleEndDateChange}
min={startDate} min={formatDateToISO(startDate)}
max={new Date().toISOString().split('T')[0]} max={formatDateToISO(new Date())}
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert" className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
lang="de"
/> />
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import { format, startOfYear } from "date-fns"; import { startOfYear } from "date-fns";
import { createContext, useMemo, useReducer } from "react"; import { createContext, useMemo, useReducer } from "react";
import { Asset, DateRange, HistoricalData, Investment } from "../types"; import { Asset, DateRange, HistoricalData, Investment } from "../types";
@ -29,8 +29,8 @@ const initialState: PortfolioState = {
assets: [], assets: [],
isLoading: false, isLoading: false,
dateRange: { dateRange: {
startDate: format(startOfYear(new Date()), 'yyyy-MM-dd'), startDate: startOfYear(new Date()),
endDate: format(new Date(), 'yyyy-MM-dd'), endDate: new Date(),
}, },
}; };

View file

@ -55,10 +55,10 @@ export const searchAssets = async (query: string): Promise<Asset[]> => {
} }
}; };
export const getHistoricalData = async (symbol: string, startDate: string, endDate: string) => { export const getHistoricalData = async (symbol: string, startDate: Date, endDate: Date) => {
try { try {
const start = Math.floor(new Date(startDate).getTime() / 1000); const start = Math.floor(startDate.getTime() / 1000);
const end = Math.floor(new Date(endDate).getTime() / 1000); const end = Math.floor(endDate.getTime() / 1000);
const params = new URLSearchParams({ const params = new URLSearchParams({
period1: start.toString(), period1: start.toString(),
@ -76,7 +76,7 @@ export const getHistoricalData = async (symbol: string, startDate: string, endDa
return { return {
historicalData: timestamp.map((time: number, index: number) => ({ historicalData: timestamp.map((time: number, index: number) => ({
date: new Date(time * 1000).toISOString().split('T')[0], date: new Date(time * 1000),
price: quotes.close[index], price: quotes.close[index],
})), })),
longName: meta.longName longName: meta.longName

View file

@ -11,7 +11,7 @@ export interface Asset {
} }
export interface HistoricalData { export interface HistoricalData {
date: string; date: Date;
price: number; price: number;
} }
@ -20,13 +20,15 @@ export interface Investment {
assetId: string; assetId: string;
type: 'single' | 'periodic'; type: 'single' | 'periodic';
amount: number; amount: number;
date?: string; date?: Date;
periodicGroupId?: string; periodicGroupId?: string;
} }
export interface PeriodicSettings { export interface PeriodicSettings {
dayOfMonth: number; dayOfMonth: number;
interval: number; interval: number;
intervalUnit: 'days' | 'weeks' | 'months' | 'quarters' | 'years';
startDate: Date;
dynamic?: { dynamic?: {
type: 'percentage' | 'fixed'; type: 'percentage' | 'fixed';
value: number; value: number;
@ -37,7 +39,7 @@ export interface PeriodicSettings {
export interface InvestmentPerformance { export interface InvestmentPerformance {
id: string; id: string;
assetName: string; assetName: string;
date: string; date: Date;
investedAmount: number; investedAmount: number;
investedAtPrice: number; investedAtPrice: number;
currentValue: number; currentValue: number;
@ -46,14 +48,14 @@ export interface InvestmentPerformance {
} }
export interface DateRange { export interface DateRange {
startDate: string; startDate: Date;
endDate: string; endDate: Date;
} }
export interface InvestmentPerformance { export interface InvestmentPerformance {
id: string; id: string;
assetName: string; assetName: string;
date: string; date: Date;
investedAmount: number; investedAmount: number;
investedAtPrice: number; investedAtPrice: number;
currentValue: number; currentValue: number;
@ -75,7 +77,7 @@ export interface PortfolioPerformance {
} }
export type DayData = { export type DayData = {
date: string; date: Date;
total: number; total: number;
invested: number; invested: number;
percentageChange: number; percentageChange: number;
@ -87,7 +89,7 @@ export interface WithdrawalPlan {
amount: number; amount: number;
interval: 'monthly' | 'yearly'; interval: 'monthly' | 'yearly';
startTrigger: 'date' | 'portfolioValue' | 'auto'; startTrigger: 'date' | 'portfolioValue' | 'auto';
startDate?: string; startDate?: Date;
startPortfolioValue?: number; startPortfolioValue?: number;
enabled: boolean; enabled: boolean;
autoStrategy?: { autoStrategy?: {
@ -98,7 +100,7 @@ export interface WithdrawalPlan {
} }
export interface ProjectionData { export interface ProjectionData {
date: string; date: Date;
value: number; value: number;
invested: number; invested: number;
withdrawals: number; withdrawals: number;
@ -112,7 +114,7 @@ export interface SustainabilityAnalysis {
} }
export interface PeriodicSettings { export interface PeriodicSettings {
startDate: string; startDate: Date;
dayOfMonth: number; dayOfMonth: number;
interval: number; interval: number;
amount: number; amount: number;

View file

@ -1,4 +1,6 @@
import { isAfter, isBefore, isSameDay } from "date-fns"; import {
addDays, addMonths, addWeeks, addYears, isAfter, isBefore, isSameDay, setDate
} from "date-fns";
import type { Asset, Investment, PeriodicSettings } from "../../types"; import type { Asset, Investment, PeriodicSettings } from "../../types";
@ -13,7 +15,7 @@ export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice
// Find price at investment date // Find price at investment date
const investmentPrice = asset.historicalData.find( const investmentPrice = asset.historicalData.find(
(data) => data.date === investment.date (data) => isSameDay(data.date, invDate)
)?.price || 0; )?.price || 0;
// if no investment price found, use the previous price // if no investment price found, use the previous price
@ -39,26 +41,42 @@ export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice
} }
}; };
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: string, assetId: string): Investment[] => {
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: Date, assetId: string): Investment[] => {
const investments: Investment[] = []; const investments: Investment[] = [];
const periodicGroupId = crypto.randomUUID(); const periodicGroupId = crypto.randomUUID();
let currentDate = new Date(settings.startDate);
// Create UTC dates
let currentDate = new Date(Date.UTC(
settings.startDate.getUTCFullYear(),
settings.startDate.getUTCMonth(),
settings.startDate.getUTCDate()
));
const end = new Date(Date.UTC(
endDate.getUTCFullYear(),
endDate.getUTCMonth(),
endDate.getUTCDate()
));
let currentAmount = settings.amount; let currentAmount = settings.amount;
const end = new Date(endDate);
while (currentDate <= end) { while (currentDate <= end) {
// Only create investment if it's on the specified day of month // For monthly/yearly intervals, ensure we're on the correct day of month
if (currentDate.getDate() === settings.dayOfMonth) { if (settings.intervalUnit !== 'days') {
currentDate = setDate(currentDate, settings.dayOfMonth);
}
// Only add investment if we haven't passed the end date
if (currentDate <= end) {
// Handle dynamic increases if configured // Handle dynamic increases if configured
if (settings.dynamic) { if (settings.dynamic) {
const yearsSinceStart = const yearsSinceStart =
(currentDate.getTime() - new Date(settings.startDate).getTime()) / (currentDate.getTime() - settings.startDate.getTime()) /
(1000 * 60 * 60 * 24 * 365); (1000 * 60 * 60 * 24 * 365);
// Check if we've reached a year interval for increase
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) { if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
if (settings.dynamic.type === 'percentage') { if (settings.dynamic.type === 'percentage') {
console.log('percentage', settings.dynamic.value, (1 + (settings.dynamic.value / 100)));
currentAmount *= (1 + (settings.dynamic.value / 100)); currentAmount *= (1 + (settings.dynamic.value / 100));
} else { } else {
currentAmount += settings.dynamic.value; currentAmount += settings.dynamic.value;
@ -70,24 +88,38 @@ export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate:
id: crypto.randomUUID(), id: crypto.randomUUID(),
type: 'periodic', type: 'periodic',
amount: currentAmount, amount: currentAmount,
date: currentDate.toISOString().split('T')[0], date: currentDate,
periodicGroupId, periodicGroupId,
assetId assetId
}); });
} }
// Move to next interval day // Calculate next date based on interval unit
const nextDate = new Date(currentDate); switch (settings.intervalUnit) {
nextDate.setDate(nextDate.getDate() + settings.interval); case 'days':
currentDate = addDays(currentDate, settings.interval);
// Ensure we maintain the correct day of month break;
if (nextDate.getDate() !== settings.dayOfMonth) { case 'weeks':
nextDate.setDate(1); currentDate = addWeeks(currentDate, settings.interval);
nextDate.setMonth(nextDate.getMonth() + 1); break;
nextDate.setDate(settings.dayOfMonth); case 'months':
currentDate = addMonths(currentDate, settings.interval);
// Ensure we maintain the correct day of month using UTC
if (currentDate.getUTCDate() !== settings.dayOfMonth) {
currentDate = setDate(currentDate, settings.dayOfMonth);
}
break;
case 'quarters':
currentDate = addMonths(currentDate, settings.interval * 3);
break;
case 'years':
currentDate = addYears(currentDate, settings.interval);
// Ensure we maintain the correct day of month using UTC
if (currentDate.getUTCDate() !== settings.dayOfMonth) {
currentDate = setDate(currentDate, settings.dayOfMonth);
}
break;
} }
currentDate = nextDate;
} }
return investments; return investments;

View file

@ -1,4 +1,4 @@
import { addMonths, differenceInYears, format } from "date-fns"; import { addMonths, differenceInYears } from "date-fns";
import type { import type {
ProjectionData, SustainabilityAnalysis, WithdrawalPlan, Asset, Investment ProjectionData, SustainabilityAnalysis, WithdrawalPlan, Asset, Investment
@ -10,7 +10,7 @@ const findOptimalStartingPoint = (
desiredWithdrawal: number, desiredWithdrawal: number,
strategy: WithdrawalPlan['autoStrategy'], strategy: WithdrawalPlan['autoStrategy'],
interval: 'monthly' | 'yearly' interval: 'monthly' | 'yearly'
): { startDate: string; requiredPortfolioValue: number } => { ): { startDate: Date; requiredPortfolioValue: number } => {
const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal; const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal;
let requiredPortfolioValue = 0; let requiredPortfolioValue = 0;
@ -42,7 +42,7 @@ const findOptimalStartingPoint = (
startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach)); startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach));
return { return {
startDate: startDate.toISOString().split('T')[0], startDate,
requiredPortfolioValue, requiredPortfolioValue,
}; };
}; };
@ -105,7 +105,7 @@ export const calculateFutureProjection = async (
future.push({ future.push({
...lastInvestment, ...lastInvestment,
date: format(currentDate, 'yyyy-MM-dd'), date: currentDate,
amount: currentAmount, amount: currentAmount,
}); });
} }
@ -208,7 +208,7 @@ export const calculateFutureProjection = async (
// Only add to projection data if within display timeframe // Only add to projection data if within display timeframe
if (currentDate <= endDateForDisplay) { if (currentDate <= endDateForDisplay) {
projectionData.push({ projectionData.push({
date: format(currentDate, 'yyyy-MM-dd'), date: currentDate,
value: Math.max(0, portfolioValue), value: Math.max(0, portfolioValue),
invested: totalInvested, invested: totalInvested,
withdrawals: monthlyWithdrawal, withdrawals: monthlyWithdrawal,

View file

@ -1,4 +1,4 @@
import { isAfter, isBefore } from "date-fns"; import { isAfter, isBefore, isSameDay } from "date-fns";
import type { Asset, InvestmentPerformance, PortfolioPerformance } from "../../types"; import type { Asset, InvestmentPerformance, PortfolioPerformance } from "../../types";
@ -76,16 +76,18 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
); );
for (const investment of relevantInvestments) { for (const investment of relevantInvestments) {
const invDate = new Date(investment.date!);
const investmentPrice = asset.historicalData.find( const investmentPrice = asset.historicalData.find(
(data) => data.date === investment.date (data) => isSameDay(data.date, invDate)
)?.price || 0; )?.price || 0;
const previousPrice = investmentPrice || asset.historicalData.filter( const previousPrice = investmentPrice || asset.historicalData.filter(
(data) => isBefore(new Date(data.date), new Date(investment.date!)) (data) => isBefore(new Date(data.date), invDate)
).reverse().find((v) => v.price !== 0)?.price || 0; ).reverse().find((v) => v.price !== 0)?.price || 0;
const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter( const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter(
(data) => isAfter(new Date(data.date), new Date(investment.date!)) (data) => isAfter(new Date(data.date), invDate)
).find((v) => v.price !== 0)?.price || 0; ).find((v) => v.price !== 0)?.price || 0;
if (buyInPrice > 0) { if (buyInPrice > 0) {
@ -128,16 +130,17 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0; const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
for (const investment of asset.investments) { for (const investment of asset.investments) {
const invDate = new Date(investment.date!);
const investmentPrice = asset.historicalData.find( const investmentPrice = asset.historicalData.find(
(data) => data.date === investment.date (data) => isSameDay(data.date, invDate)
)?.price || 0; )?.price || 0;
const previousPrice = investmentPrice || asset.historicalData.filter( const previousPrice = investmentPrice || asset.historicalData.filter(
(data) => isBefore(new Date(data.date), new Date(investment.date!)) (data) => isBefore(new Date(data.date), invDate)
).reverse().find((v) => v.price !== 0)?.price || 0; ).reverse().find((v) => v.price !== 0)?.price || 0;
const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter( const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter(
(data) => isAfter(new Date(data.date), new Date(investment.date!)) (data) => isAfter(new Date(data.date), invDate)
).find((v) => v.price !== 0)?.price || 0; ).find((v) => v.price !== 0)?.price || 0;
const shares = buyInPrice > 0 ? investment.amount / buyInPrice : 0; const shares = buyInPrice > 0 ? investment.amount / buyInPrice : 0;
@ -165,24 +168,9 @@ export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerfor
? ((ttworValue - totalInvested) / totalInvested) * 100 ? ((ttworValue - totalInvested) / totalInvested) * 100
: 0; : 0;
// Berechne die jährliche Performance
// const performancePerAnnoPerformance = (() => {
// if (!earliestDate || totalInvested === 0) return 0;
// const years = differenceInDays(new Date(), earliestDate) / 365;
// if (years < 0.01) return 0; // Verhindere Division durch sehr kleine Zahlen
// // Formel: (1 + r)^n = FV/PV
// // r = (FV/PV)^(1/n) - 1
// const totalReturn = totalCurrentValue / totalInvested;
// const annualizedReturn = Math.pow(totalReturn, 1 / years) - 1;
// return annualizedReturn * 100;
// })();
const performancePerAnnoPerformance = annualPerformances.reduce((acc, curr) => acc + curr.percentage, 0) / annualPerformances.length; const performancePerAnnoPerformance = annualPerformances.reduce((acc, curr) => acc + curr.percentage, 0) / annualPerformances.length;
console.log(performancePerAnnoPerformance, annualPerformances);
return { return {
investments, investments,
summary: { summary: {

View file

@ -1,4 +1,4 @@
import { addDays, isAfter, isBefore } from "date-fns"; import { addDays, isAfter, isBefore, isSameDay } from "date-fns";
import { calculateAssetValueAtDate } from "./assetValue"; import { calculateAssetValueAtDate } from "./assetValue";
@ -8,14 +8,14 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
const { startDate, endDate } = dateRange; const { startDate, endDate } = dateRange;
const data: DayData[] = []; const data: DayData[] = [];
let currentDate = new Date(startDate); let currentDate = startDate;
const end = new Date(endDate); const end = endDate;
const beforeValue: { [assetId: string]: number } = {}; const beforeValue: { [assetId: string]: number } = {};
while (isBefore(currentDate, end)) { while (isBefore(currentDate, end)) {
const dayData: DayData = { const dayData: DayData = {
date: currentDate.toISOString().split('T')[0], date: currentDate,
total: 0, total: 0,
invested: 0, invested: 0,
percentageChange: 0, percentageChange: 0,
@ -38,7 +38,7 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
// Get historical price for the asset // Get historical price for the asset
const currentValueOfAsset = asset.historicalData.find( const currentValueOfAsset = asset.historicalData.find(
(data) => data.date === dayData.date (data) => isSameDay(data.date, dayData.date)
)?.price || beforeValue[asset.id]; )?.price || beforeValue[asset.id];
beforeValue[asset.id] = currentValueOfAsset; beforeValue[asset.id] = currentValueOfAsset;
@ -52,15 +52,16 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
dayData.total += investedValue || 0; dayData.total += investedValue || 0;
dayData.assets[asset.id] = currentValueOfAsset; dayData.assets[asset.id] = currentValueOfAsset;
const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100; const performancePercentage = investedValue > 0
if (!Number.isNaN(percent) && investedValue && investedValue > 0) { ? ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100
: 0;
weightedPercents.push({ weightedPercents.push({
percent, percent: performancePercentage,
weight: investedValue weight: investedValue
}); });
} }
} }
}
// Calculate weighted average percentage change // Calculate weighted average percentage change
if (weightedPercents.length > 0) { if (weightedPercents.length > 0) {
@ -71,6 +72,14 @@ export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) =
dayData.percentageChange = 0; dayData.percentageChange = 0;
} }
const totalInvested = dayData.invested; // Total invested amount for the day
const totalCurrentValue = dayData.total; // Total current value for the day
dayData.percentageChange = totalInvested > 0
? ((totalCurrentValue - totalInvested) / totalInvested) * 100
: 0;
currentDate = addDays(currentDate, 1); currentDate = addDays(currentDate, 1);
data.push(dayData); data.push(dayData);
} }