Initial commit: Portfolio Simulator Implementation

This commit is contained in:
tomato6966 2024-12-21 23:15:39 +01:00
commit 0912dbdb97
31 changed files with 6492 additions and 0 deletions

3
.bolt/config.json Normal file
View file

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

8
.bolt/prompt Normal file
View file

@ -0,0 +1,8 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.

53
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main # oder master, je nachdem welchen Branch-Namen Sie verwenden
workflow_dispatch: # Ermöglicht manuelles Triggern
permissions:
contents: read
pages: write
id-token: write
# Erlaubt nur einen gleichzeitigen Deploy
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build-and-deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Build
run: npm run build
env:
VITE_BASE_URL: '/${{ github.event.repository.name }}'
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: './dist'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

28
eslint.config.js Normal file
View file

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4472
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "investment-portfolio-tracker",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.24.1",
"date-fns": "^3.3.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.12.1",
"zustand": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/node": "^22.10.2",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

57
src/App.tsx Normal file
View file

@ -0,0 +1,57 @@
import { Moon, Plus, Sun } from "lucide-react";
import React, { useState } from "react";
import { AddAssetModal } from "./components/AddAssetModal";
import { InvestmentFormWrapper } from "./components/InvestmentForm";
import { PortfolioChart } from "./components/PortfolioChart";
import { PortfolioTable } from "./components/PortfolioTable";
import { useDarkMode } from "./providers/DarkModeProvider";
export default function App() {
const [isAddingAsset, setIsAddingAsset] = useState(false);
const { isDarkMode, toggleDarkMode } = useDarkMode();
return (
<div className={`app ${isDarkMode ? 'dark' : ''}`}>
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-8 transition-colors">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold dark:text-white">Portfolio Simulator</h1>
<div className="flex gap-4">
<button
onClick={toggleDarkMode}
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
aria-label="Toggle dark mode"
>
{isDarkMode ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-gray-600" />
)}
</button>
<button
onClick={() => setIsAddingAsset(true)}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
<Plus className="w-5 h-5" />
Add Asset
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-8 mb-8 dark:text-gray-300">
<div className="col-span-3">
<PortfolioChart/>
</div>
<div className="col-span-1">
<InvestmentFormWrapper />
</div>
</div>
<PortfolioTable />
{isAddingAsset && <AddAssetModal onClose={() => setIsAddingAsset(false)} />}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,118 @@
import { Search, X } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { getHistoricalData, searchAssets } from "../services/yahooFinanceService";
import { usePortfolioStore } from "../store/portfolioStore";
import { Asset } from "../types";
export const AddAssetModal = ({ onClose }: { onClose: () => void }) => {
const [search, setSearch] = useState('');
const [searchResults, setSearchResults] = useState<Asset[]>([]);
const [loading, setLoading] = useState(false);
const { addAsset, dateRange } = usePortfolioStore((state) => ({
addAsset: state.addAsset,
dateRange: state.dateRange,
}));
const searchTimeoutRef = useRef<NodeJS.Timeout>();
const debouncedSearch = useCallback((query: string) => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
handleSearch(query);
}, 500);
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, []);
const handleSearch = async (query: string) => {
if (query.length < 2) return;
setLoading(true);
try {
const results = await searchAssets(query);
setSearchResults(results);
} catch (error) {
console.error('Error searching assets:', error);
} finally {
setLoading(false);
}
};
const handleAssetSelect = async (asset: Asset) => {
setLoading(true);
try {
const historicalData = await getHistoricalData(
asset.symbol,
dateRange.startDate,
dateRange.endDate
);
const assetWithHistory = {
...asset,
historicalData,
};
addAsset(assetWithHistory);
onClose();
} catch (error) {
console.error('Error fetching historical data:', error);
} finally {
setLoading(false);
}
};
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">Add Asset</h2>
<button onClick={onClose} className="p-2">
<X className="w-6 h-6 dark:text-gray-200" />
</button>
</div>
<div className="relative mb-4">
<input
type="text"
placeholder="Search by symbol or name..."
className="w-full p-2 pr-10 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
value={search}
onChange={(e) => {
setSearch(e.target.value);
debouncedSearch(e.target.value);
}}
/>
<Search className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" />
</div>
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="text-center py-4">Loading...</div>
) : (
searchResults.map((result) => (
<button
key={result.symbol}
className="w-full text-left p-3 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-900 rounded"
onClick={() => handleAssetSelect(result)}
>
<div className="font-medium">{result.name}</div>
<div className="text-sm text-gray-600">
Symbol: {result.symbol}
</div>
</button>
))
)}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,41 @@
interface DateRangePickerProps {
startDate: string;
endDate: string;
onStartDateChange: (date: string) => void;
onEndDateChange: (date: string) => void;
}
export const DateRangePicker = ({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
}: DateRangePickerProps) => {
return (
<div className="flex gap-4 items-center mb-4 dark:text-gray-300">
<div>
<label className="block text-sm font-medium mb-1">From</label>
<input
type="date"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
max={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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">To</label>
<input
type="date"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
min={startDate}
max={new Date().toISOString().split('T')[0]}
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"
/>
</div>
</div>
);
};

View file

@ -0,0 +1,79 @@
import { X } from "lucide-react";
import { useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore";
import { Investment } from "../types";
interface EditInvestmentModalProps {
investment: Investment;
assetId: string;
onClose: () => void;
}
export const EditInvestmentModal = ({ investment, assetId, onClose }: EditInvestmentModalProps) => {
const updateInvestment = usePortfolioStore((state) => state.updateInvestment);
const [amount, setAmount] = useState(investment.amount.toString());
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
updateInvestment(assetId, investment.id, {
...investment,
amount: parseFloat(amount),
});
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Edit Investment</h2>
<button onClick={onClose} className="p-2">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Investment Amount
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full p-2 border rounded"
step="0.01"
min="0"
required
/>
</div>
{investment.type === 'periodic' && (
<div className="mb-4">
<p className="text-sm text-gray-600">
Note: Editing a periodic investment will affect all future investments.
</p>
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border rounded hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
);
};

View file

@ -0,0 +1,262 @@
import React, { useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore";
import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
export const InvestmentFormWrapper = () => {
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
const { assets, clearAssets } = usePortfolioStore((state) => ({
assets: state.assets,
clearAssets: state.clearAssets,
}));
const handleClearAssets = () => {
if (window.confirm('Are you sure you want to delete all assets? This action cannot be undone.')) {
clearAssets();
setSelectedAsset(null);
}
};
return (
<div className="bg-white dark:bg-slate-800 rounded-lg shadow h-full dark:shadow-black/60">
<div className="p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold dark:text-gray-200">Add Investment</h2>
{assets.length > 0 && (
<button
onClick={handleClearAssets}
className="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
type="button"
>
Clear Assets
</button>
)}
</div>
<div className="mb-4">
<select
value={selectedAsset || ''}
disabled={assets.length === 0}
onChange={(e) => setSelectedAsset(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 ${assets.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<option value="">Select Asset</option>
{assets.map((asset) => (
<option key={asset.id} value={asset.id}>
{asset.name}
</option>
))}
</select>
</div>
</div>
{
selectedAsset && (
<div className="overflow-y-scroll scrollbar-styled max-h-[380px] p-6 pr-3">
<InvestmentForm assetId={selectedAsset} />
</div>
)
}
</div>
);
}
const InvestmentForm = ({ assetId }: { assetId: string }) => {
const [type, setType] = useState<'single' | 'periodic'>('single');
const [amount, setAmount] = useState('');
const [date, setDate] = useState('');
const [dayOfMonth, setDayOfMonth] = useState('1');
const [interval, setInterval] = useState('30');
const [isDynamic, setIsDynamic] = useState(false);
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
const [dynamicValue, setDynamicValue] = useState('');
const [yearInterval, setYearInterval] = useState('1');
const { dateRange, addInvestment } = usePortfolioStore((state) => ({
dateRange: state.dateRange,
addInvestment: state.addInvestment,
}));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (type === "single") {
const investment = {
id: crypto.randomUUID(),
assetId,
type,
amount: parseFloat(amount),
date
};
addInvestment(assetId, investment);
} else {
const periodicSettings = {
startDate: date,
dayOfMonth: parseInt(dayOfMonth),
interval: parseInt(interval),
amount: parseFloat(amount),
...(isDynamic ? {
dynamic: {
type: dynamicType,
value: parseFloat(dynamicValue),
yearInterval: parseInt(yearInterval),
},
} : undefined),
};
const investments = generatePeriodicInvestments(
periodicSettings,
new Date(dateRange.endDate),
assetId,
);
for(const investment of investments) {
addInvestment(assetId, investment);
}
}
// Reset form
setAmount('');
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Investment Type</label>
<select
value={type}
onChange={(e) => setType(e.target.value as 'single' | 'periodic')}
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
>
<option value="single">Single Investment</option>
<option value="periodic">Periodic Investment</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">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"
min="0"
step="0.01"
required
/>
</div>
{type === 'single' ? (
<div>
<label className="block text-sm font-medium mb-1">Date</label>
<input
type="date"
value={date}
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"
required
/>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium mb-1">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>
<label className="block text-sm font-medium mb-1">Sparplan-Start Date</label>
<input
type="date"
value={date}
// the "dayOf the month should not be change able, due to the day of the"
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"
required
/>
<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 className="flex items-center gap-2">
<input
type="checkbox"
checked={isDynamic}
onChange={(e) => setIsDynamic(e.target.checked)}
id="dynamic"
/>
<label htmlFor="dynamic">Enable Periodic Investment Increase</label>
</div>
{isDynamic && (
<>
<div>
<label className="block text-sm font-medium mb-1">
Increase 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">
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">
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>
</>
)}
</>
)}
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
>
Add Investment
</button>
</form>
);
};

View file

@ -0,0 +1,323 @@
import { format } from "date-fns";
import { BarChart2, Eye, EyeOff, Maximize2, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import {
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
} from "recharts";
import { useDarkMode } from "../providers/DarkModeProvider";
import { usePortfolioStore } from "../store/portfolioStore";
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
import { DateRangePicker } from "./DateRangePicker";
const LIGHT_MODE_COLORS = [
'#2563eb', '#dc2626', '#059669', '#7c3aed', '#ea580c',
'#0891b2', '#be123c', '#1d4ed8', '#b91c1c', '#047857',
'#6d28d9', '#c2410c', '#0e7490', '#9f1239', '#1e40af',
'#991b1b', '#065f46', '#5b21b6', '#9a3412', '#155e75',
'#881337', '#1e3a8a', '#7f1d1d', '#064e3b', '#4c1d95'
];
const DARK_MODE_COLORS = [
'#60a5fa', '#f87171', '#34d399', '#a78bfa', '#fb923c',
'#22d3ee', '#fb7185', '#3b82f6', '#ef4444', '#10b981',
'#8b5cf6', '#f97316', '#06b6d4', '#f43f5e', '#2563eb',
'#dc2626', '#059669', '#7c3aed', '#ea580c', '#0891b2',
'#be123c', '#1d4ed8', '#b91c1c', '#047857', '#6d28d9'
];
const getHexColor = (usedColors: Set<string>, isDarkMode: boolean): string => {
const colorPool = isDarkMode ? DARK_MODE_COLORS : LIGHT_MODE_COLORS;
// Find first unused color
const availableColor = colorPool.find(color => !usedColors.has(color));
if (availableColor) {
return availableColor;
}
// Fallback to random color if all predefined colors are used
return `#${Math.floor(Math.random()*16777215).toString(16)}`;
};
export const PortfolioChart = () => {
const [isFullscreen, setIsFullscreen] = useState(false);
const [hideAssets, setHideAssets] = useState(false);
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set());
const { isDarkMode } = useDarkMode();
const { assets, dateRange, updateDateRange } = usePortfolioStore((state) => ({
assets: state.assets,
dateRange: state.dateRange,
updateDateRange: state.updateDateRange,
}));
const assetColors: Record<string, string> = useMemo(() => {
const usedColors = new Set<string>();
return assets.reduce((colors, asset) => {
const color = getHexColor(usedColors, isDarkMode);
usedColors.add(color);
return {
...colors,
[asset.id]: color,
};
}, {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets.map(a => a.id).join(','), isDarkMode]);
const data = useMemo(() => calculatePortfolioValue(assets, dateRange).filter(v => Object.keys(v.assets).length > 0), [assets, dateRange]);
const allAssetsInvestedKapitals = useMemo<Record<string, number>>(() => {
const investedKapitals: Record<string, number> = {};
for(const asset of assets) {
investedKapitals[asset.id] = asset.investments.reduce((acc, curr) => acc + curr.amount, 0);
}
return investedKapitals;
}, [assets]);
// Calculate percentage changes for each asset
const processedData = useMemo(() => data.map(point => {
const processed: { [key: string]: number | string } = {
date: point.date,
total: point.total,
invested: point.invested,
percentageChange: point.percentageChange,
};
processed["ttwor"] = 0;
for(const asset of assets) {
const initialPrice = data[0].assets[asset.id];
const currentPrice = point.assets[asset.id];
if (initialPrice && currentPrice) {
processed[`${asset.id}_price`] = currentPrice;
const percentDecimal = ((currentPrice - initialPrice) / initialPrice);
processed[`${asset.id}_percent`] = percentDecimal * 100;
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;
// add a processed["ttwor"] ttwor is what if you invested all of the kapital of all assets at the start of the period
return processed;
}), [data, assets, allAssetsInvestedKapitals]);
const toggleAsset = useCallback((assetId: string) => {
const newHiddenAssets = new Set(hiddenAssets);
if (newHiddenAssets.has(assetId)) {
newHiddenAssets.delete(assetId);
} else {
newHiddenAssets.add(assetId);
}
setHiddenAssets(newHiddenAssets);
}, [hiddenAssets]);
const toggleAllAssets = useCallback(() => {
setHideAssets(!hideAssets);
setHiddenAssets(new Set());
}, [hideAssets] );
const CustomLegend = useCallback(({ payload }: any) => {
return (
<div className="flex flex-col gap-2 p-4 rounded-lg shadow-md dark:shadow-black/60">
<div className="flex items-center justify-between gap-2 pb-2 border-b">
<div className="flex items-center gap-1">
<BarChart2 className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium">Chart Legend</span>
</div>
<button
onClick={toggleAllAssets}
className="flex items-center gap-1 px-2 py-1 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-800"
>
{hideAssets ? (
<>
<Eye className="w-4 h-4" />
Show All
</>
) : (
<>
<EyeOff className="w-4 h-4" />
Hide All
</>
)}
</button>
</div>
<div className="flex flex-wrap gap-4">
{payload.map((entry: any, index: number) => {
const assetId = entry.dataKey.split('_')[0];
const isHidden = hideAssets || hiddenAssets.has(assetId);
return (
<button
key={`asset-${index}`}
onClick={() => toggleAsset(assetId)}
className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${
isHidden ? 'opacity-40' : ''
} hover:bg-gray-100 dark:hover:bg-gray-800`}
>
<div className="flex items-center gap-2">
<div
className="w-8 h-[3px]"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm">{entry.value.replace(' (%)', '')}</span>
{isHidden ? (
<Eye className="w-3 h-3 text-gray-400 dark:text-gray-600" />
) : (
<EyeOff className="w-3 h-3 text-gray-400 dark:text-gray-600" />
)}
</div>
</button>
);
})}
</div>
</div>
);
}, [hideAssets, hiddenAssets, toggleAsset, toggleAllAssets]);
const ChartContent = useCallback(() => (
<>
<div className="flex justify-between items-center mb-4">
<DateRangePicker
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onStartDateChange={(date) => updateDateRange({ ...dateRange, startDate: date })}
onEndDateChange={(date) => updateDateRange({ ...dateRange, endDate: date })}
/>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
<Maximize2 className="w-5 h-5" />
</button>
</div>
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"}>
<ResponsiveContainer>
<LineChart data={processedData}>
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
<XAxis
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
dataKey="date"
tickFormatter={(date) => format(new Date(date), 'MMM dd')}
/>
<YAxis
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
yAxisId="left"
tickFormatter={(value) => `${value.toLocaleString()}`}
/>
<YAxis
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
yAxisId="right"
orientation="right"
tickFormatter={(value) => `${value.toFixed(2)}%`}
/>
<Tooltip
formatter={(value: number, name: string, item) => {
const assetKey = name.split('_')[0] as keyof typeof assets;
const processedKey = `${assets.find(a => a.name === name.replace(" (%)", ""))?.id}_price`;
if (name === "avg. Portfolio % gain")
return [`${value.toFixed(2)}%`, name];
if (name === "TTWOR")
return [`${value.toLocaleString()}€ (${item.payload["ttwor_percent"].toFixed(2)}%)`, name];
if (name === "Portfolio-Value" || name === "Invested Capital")
return [`${value.toLocaleString()}`, name];
if (name.includes("(%)"))
return [`${Number(item.payload[processedKey]).toFixed(2)}${value.toFixed(2)}%`, name.replace(" (%)", "")];
return [`${value.toLocaleString()}€ (${((value - Number(assets[assetKey])) / Number(assets[assetKey]) * 100).toFixed(2)}%)`, name];
}}
labelFormatter={(date) => format(new Date(date), 'MMM dd, yyyy')}
/>
<Legend content={<CustomLegend />} />
<Line
type="monotone"
dataKey="total"
name="Portfolio-Value"
hide={hideAssets || hiddenAssets.has("total")}
stroke="#000"
strokeWidth={2}
dot={false}
yAxisId="left"
/>
<Line
type="monotone"
dataKey="invested"
name="Invested Capital"
hide={hideAssets || hiddenAssets.has("invested")}
stroke="#666"
strokeDasharray="5 5"
dot={false}
yAxisId="left"
/>
<Line
type="monotone"
dataKey="ttwor"
name="TTWOR"
strokeDasharray="5 5"
stroke="#a64c79"
hide={hideAssets || hiddenAssets.has("ttwor")}
dot={false}
yAxisId="left"
/>
{assets.map((asset) => {
return (
<Line
key={asset.id}
type="monotone"
hide={hideAssets || hiddenAssets.has(asset.id)}
dataKey={`${asset.id}_percent`}
name={`${asset.name} (%)`}
stroke={assetColors[asset.id] || "red"}
dot={false}
yAxisId="right"
/>
);
})}
<Line
type="monotone"
dataKey="percentageChange"
hide={hideAssets || hiddenAssets.has("percentageChange")}
dot={false}
name="avg. Portfolio % gain"
stroke="#a0a0a0"
yAxisId="right"
/>
</LineChart>
</ResponsiveContainer>
</div>
<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),
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
</i>
</>
), [assets, isDarkMode, assetColors, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, updateDateRange, isFullscreen]);
if (isFullscreen) {
return (
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Portfolio Chart</h2>
<button
onClick={() => setIsFullscreen(false)}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
>
<X className="w-6 h-6" />
</button>
</div>
<ChartContent />
</div>
);
}
return (
<div className="w-full bg-white dark:bg-slate-800 p-4 rounded-lg shadow dark:shadow-black/60">
<ChartContent />
</div>
);
};

View file

@ -0,0 +1,225 @@
import { format } from "date-fns";
import { HelpCircle, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { usePortfolioStore } from "../store/portfolioStore";
import { Investment } from "../types";
import { calculateInvestmentPerformance } from "../utils/calculations/performance";
import { EditInvestmentModal } from "./EditInvestmentModal";
interface TooltipProps {
content: string | JSX.Element;
children: React.ReactNode;
}
const Tooltip = ({ content, children }: TooltipProps) => {
const [show, setShow] = useState(false);
return (
<div className="relative inline-block">
<div
className="flex items-center gap-1 cursor-help"
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
>
{children}
<HelpCircle className="w-4 h-4 text-gray-400" />
</div>
{show && (
<div className="absolute z-50 w-64 p-2 text-sm bg-black text-white rounded shadow-lg dark:shadow-black/60 -left-20 -bottom-2 transform translate-y-full">
{content}
</div>
)}
</div>
);
};
export const PortfolioTable = () => {
const { assets, removeInvestment, clearInvestments } = usePortfolioStore((state) => ({
assets: state.assets,
removeInvestment: state.removeInvestment,
clearInvestments: state.clearInvestments,
}));
const [editingInvestment, setEditingInvestment] = useState<{
investment: Investment;
assetId: string;
} | null>(null);
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]);
const averagePerformance = useMemo(() => {
return (performance.investments.reduce((sum, inv) => sum + inv.performancePercentage, 0) / performance.investments.length).toFixed(2);
}, [performance.investments]);
const handleDelete = useCallback((investmentId: string, assetId: string) => {
if (window.confirm("Are you sure you want to delete this investment?")) {
removeInvestment(assetId, investmentId);
}
}, [removeInvestment]);
const handleClearAll = useCallback(() => {
if (window.confirm("Are you sure you want to clear all investments?")) {
clearInvestments();
}
}, [clearInvestments]);
const performanceTooltip = useMemo(() => (
<div className="space-y-2">
<p>The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%</p>
<p>The average performance of all positions is {averagePerformance}%</p>
<p className="text-xs mt-2">
Note: An average performance of positions doesn't always match your entire portfolio's average,
especially with single investments or investments on different time ranges.
</p>
</div>
), [performance.summary.performancePercentage, averagePerformance]);
const buyInTooltip = useMemo(() => (
<div className="space-y-2">
<p>"Buy-in" shows the asset's price when that position was bought.</p>
<p>"Avg" shows the average buy-in price across all positions for that asset.</p>
</div>
), []);
const currentAmountTooltip = useMemo(() => (
"The current value of your investment based on the latest market price."
), []);
const ttworTooltip = useMemo(() => (
<div className="space-y-2">
<p>Time Travel Without Risk (TTWOR) shows how your portfolio would have performed if all investments had been made at the beginning of the period.</p>
<p className="text-xs mt-2">
It helps to evaluate the impact of your investment timing strategy compared to a single early investment.
</p>
</div>
), []);
return (
<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">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold dark:text-gray-100">Portfolio's <u>Positions</u> Overview</h2>
<button
onClick={handleClearAll}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
Clear All Investments
</button>
</div>
<div className="relative rounded-lg overflow-hidden">
<table className="min-w-full bg-white dark:bg-slate-800">
<thead>
<tr className="bg-gray-100 dark:bg-slate-700 text-left">
<th className="px-4 py-2 first:rounded-tl-lg">Asset</th>
<th className="px-4 py-2">Type</th>
<th className="px-4 py-2">Date</th>
<th className="px-4 py-2">Invested Amount</th>
<th className="px-4 py-2">
<Tooltip content={currentAmountTooltip}>
Current Amount
</Tooltip>
</th>
<th className="px-4 py-2">
<Tooltip content={buyInTooltip}>
Buy-In (avg)
</Tooltip>
</th>
<th className="px-4 py-2">
<Tooltip content={performanceTooltip}>
Performance (%)
</Tooltip>
</th>
<th className="px-4 py-2 last:rounded-tr-lg">Actions</th>
</tr>
</thead>
<tbody>
{performance.summary && (
<>
<tr className="font-bold bg-gray-50 dark:bg-slate-700 border-t border-gray-200 dark:border-slate-600">
<td className="px-4 py-2">Total Portfolio</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">{performance.summary.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{performance.summary.currentValue.toFixed(2)}</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">
{performance.summary.performancePercentage.toFixed(2)}%
<i className="text-xs text-gray-500 dark:text-gray-400">(avg. {averagePerformance}%)</i>
</td>
<td className="px-4 py-2"></td>
</tr>
<tr className="italic dark:text-gray-500 border-t border-gray-200 dark:border-slate-600 ">
<td className="px-4 py-2">TTWOR</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2">{performance.investments[0]?.date}</td>
<td className="px-4 py-2">{performance.summary.totalInvested.toFixed(2)}</td>
<td className="px-4 py-2">{performance.summary.ttworValue.toFixed(2)}</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2"><Tooltip content={ttworTooltip}>{performance.summary.ttworPercentage.toFixed(2)}%</Tooltip></td>
<td className="px-4 py-2"></td>
</tr>
</>
)}
{performance.investments.map((inv, index) => {
const asset = assets.find(a => a.name === inv.assetName)!;
const investment = asset.investments.find(i => i.id === inv.id)! || inv;
const filtered = performance.investments.filter(v => v.assetName === inv.assetName);
const avgBuyIn = filtered.reduce((acc, curr) => acc + curr.investedAtPrice, 0) / filtered.length;
const isLast = index === performance.investments.length - 1;
return (
<tr key={inv.id} className={`border-t border-gray-200 dark:border-slate-600 ${isLast ? 'last:rounded-b-lg' : ''}`}>
<td className={`px-4 py-2 ${isLast ? 'first:rounded-bl-lg' : ''}`}>{inv.assetName}</td>
<td className="px-4 py-2">
{investment?.type === 'periodic' ? (
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
<RefreshCw className="w-4 h-4 mr-1" />
Sparplan
</span>
) : (
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-full">
<ShoppingBag className="w-4 h-4 mr-1" />
Einmalig
</span>
)}
</td>
<td className="px-4 py-2">{format(new Date(inv.date), 'dd.MM.yyyy')}</td>
<td className="px-4 py-2">{inv.investedAmount.toFixed(2)}</td>
<td className="px-4 py-2">{inv.currentValue.toFixed(2)}</td>
<td className="px-4 py-2">{inv.investedAtPrice.toFixed(2)} ({avgBuyIn.toFixed(2)})</td>
<td className="px-4 py-2">{inv.performancePercentage.toFixed(2)}%</td>
<td className={`px-4 py-2 ${isLast ? 'last:rounded-br-lg' : ''}`}>
<div className="flex gap-2">
<button
onClick={() => setEditingInvestment({ investment, assetId: asset.id })}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(inv.id, asset.id)}
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{editingInvestment && (
<EditInvestmentModal
investment={editingInvestment.investment}
assetId={editingInvestment.assetId}
onClose={() => setEditingInvestment(null)}
/>
)}
</div>
);
};

76
src/index.css Normal file
View file

@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Modern Scrollbar Styling */
/* Webkit (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #94a3b8;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: #64748b;
}
/* Ensure transparent background for the scrollbar area */
::-webkit-scrollbar-corner,
::-webkit-scrollbar-track-piece {
background: transparent !important;
}
/* Dark mode */
.dark ::-webkit-scrollbar {
background: black !important;
}
.dark ::-webkit-scrollbar-track {
background: black !important;
}
.dark ::-webkit-scrollbar-thumb {
background-color: #475569 !important;
}
.dark ::-webkit-scrollbar-thumb:hover {
background-color: #64748b !important;
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #94a3b8 #1d2127;
}
.dark * {
scrollbar-color: #475569 #1d212799 !important;
}
/* For Internet Explorer */
body {
-ms-overflow-style: auto;
}
/* Remove default white background in dark mode */
.dark ::-webkit-scrollbar,
.dark ::-webkit-scrollbar-track,
.dark ::-webkit-scrollbar-corner,
.dark ::-webkit-scrollbar-track-piece {
background-color: transparent !important;
}
/* Ensure the app background extends properly */
html, body {
background: inherit;
}

15
src/main.tsx Normal file
View file

@ -0,0 +1,15 @@
import "./index.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { DarkModeProvider } from "./providers/DarkModeProvider.tsx";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<DarkModeProvider>
<App />
</DarkModeProvider>
</StrictMode>
);

View file

@ -0,0 +1,46 @@
import { createContext, useContext, useEffect, useState } from "react";
interface DarkModeContextType {
isDarkMode: boolean;
toggleDarkMode: () => void;
}
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
export const useDarkMode = () => {
const context = useContext(DarkModeContext);
if (!context) {
throw new Error('useDarkMode must be used within a DarkModeProvider');
}
return context;
};
export const DarkModeProvider = ({ children }: { children: React.ReactNode }) => {
const [isDarkMode, setIsDarkMode] = useState(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('darkMode');
if (saved !== null) {
return saved === 'true';
}
return window.matchMedia('(prefers-color-scheme: dark)')?.matches ?? true;
}
return true;
});
useEffect(() => {
localStorage.setItem('darkMode', isDarkMode.toString());
if (isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDarkMode]);
const toggleDarkMode = () => setIsDarkMode(prev => !prev);
return (
<DarkModeContext.Provider value={{ isDarkMode, toggleDarkMode }}>
{children}
</DarkModeContext.Provider>
);
};

View file

@ -0,0 +1,108 @@
import { Asset } from "../types";
interface YahooQuoteDocument {
symbol: string;
shortName: string;
regularMarketPrice: {
raw: number;
fmt: string;
};
regularMarketChange: {
raw: number;
fmt: string;
};
regularMarketPercentChange: {
raw: number;
fmt: string;
};
exchange: string;
quoteType: string;
}
interface YahooSearchResponse {
finance: {
result: [{
documents: YahooQuoteDocument[];
}];
error: null | string;
};
}
interface YahooChartResult {
timestamp: number[];
indicators: {
quote: [{
close: number[];
}];
};
}
export const searchAssets = async (query: string): Promise<Asset[]> => {
try {
const params = new URLSearchParams({
query,
lang: 'en-US',
type: 'equity,etf',
});
const response = await fetch(`/yahoo/v1/finance/lookup?${params}`);
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json() as YahooSearchResponse;
if (data.finance.error) {
throw new Error(data.finance.error);
}
if (!data.finance.result?.[0]?.documents) {
return [];
}
return data.finance.result[0].documents
.filter(quote => quote.quoteType === 'equity' || quote.quoteType === 'etf')
.map((quote) => ({
id: quote.symbol,
isin: '', // not provided by Yahoo Finance API
wkn: '', // not provided by Yahoo Finance API
name: quote.shortName,
symbol: quote.symbol,
price: quote.regularMarketPrice.raw,
priceChange: quote.regularMarketChange.raw,
priceChangePercent: quote.regularMarketPercentChange.raw,
exchange: quote.exchange,
historicalData: [],
investments: [],
}));
} catch (error) {
console.error('Error searching assets:', error);
return [];
}
};
export const getHistoricalData = async (symbol: string, startDate: string, endDate: string) => {
try {
const start = Math.floor(new Date(startDate).getTime() / 1000);
const end = Math.floor(new Date(endDate).getTime() / 1000);
const params = new URLSearchParams({
period1: start.toString(),
period2: end.toString(),
interval: '1d',
});
const response = await fetch(`/yahoo/v8/finance/chart/${symbol}?${params}`);
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
const { timestamp, indicators } = data.chart.result[0] as YahooChartResult;
const quotes = indicators.quote[0];
return timestamp.map((time: number, index: number) => ({
date: new Date(time * 1000).toISOString().split('T')[0],
price: quotes.close[index],
}));
} catch (error) {
console.error('Error fetching historical data:', error);
return [];
}
};

View file

@ -0,0 +1,80 @@
import { format, startOfYear } from "date-fns";
import { create } from "zustand";
import { Asset, DateRange, HistoricalData, Investment } from "../types";
interface PortfolioState {
assets: Asset[];
dateRange: DateRange;
addAsset: (asset: Asset) => void;
removeAsset: (assetId: string) => void;
clearAssets: () => void;
addInvestment: (assetId: string, investment: Investment) => void;
removeInvestment: (assetId: string, investmentId: string) => void;
updateDateRange: (dateRange: DateRange) => void;
updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[]) => void;
updateInvestment: (assetId: string, investmentId: string, updatedInvestment: Investment) => void;
clearInvestments: () => void;
}
export const usePortfolioStore = create<PortfolioState>((set) => ({
assets: [],
dateRange: {
startDate: format(startOfYear(new Date()), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),
},
addAsset: (asset) =>
set((state) => ({ assets: [...state.assets, asset] })),
removeAsset: (assetId) =>
set((state) => ({
assets: state.assets.filter((asset) => asset.id !== assetId),
})),
clearAssets: () =>
set(() => ({ assets: [] })),
addInvestment: (assetId, investment) =>
set((state) => ({
assets: state.assets.map((asset) =>
asset.id === assetId
? { ...asset, investments: [...asset.investments, investment] }
: asset
),
})),
removeInvestment: (assetId, investmentId) =>
set((state) => ({
assets: state.assets.map((asset) =>
asset.id === assetId
? {
...asset,
investments: asset.investments.filter((inv) => inv.id !== investmentId),
}
: asset
),
})),
updateDateRange: (dateRange) =>
set(() => ({ dateRange })),
updateAssetHistoricalData: (assetId, historicalData) =>
set((state) => ({
assets: state.assets.map((asset) =>
asset.id === assetId
? { ...asset, historicalData }
: asset
),
})),
updateInvestment: (assetId, investmentId, updatedInvestment) =>
set((state) => ({
assets: state.assets.map((asset) =>
asset.id === assetId
? {
...asset,
investments: asset.investments.map((inv) =>
inv.id === investmentId ? updatedInvestment : inv
),
}
: asset
),
})),
clearInvestments: () =>
set((state) => ({
assets: state.assets.map((asset) => ({ ...asset, investments: [] })),
})),
}));

50
src/types/index.ts Normal file
View file

@ -0,0 +1,50 @@
export interface Asset {
id: string;
isin: string;
name: string;
wkn: string;
symbol: string;
historicalData: HistoricalData[];
investments: Investment[];
}
export interface HistoricalData {
date: string;
price: number;
}
export interface Investment {
id: string;
assetId: string;
type: 'single' | 'periodic';
amount: number;
date?: string;
periodicGroupId?: string;
}
export interface PeriodicSettings {
dayOfMonth: number;
interval: number;
dynamic?: {
type: 'percentage' | 'fixed';
value: number;
yearInterval: number;
};
}
export interface InvestmentPerformance {
id: string;
assetName: string;
date: string;
investedAmount: number;
investedAtPrice: number;
currentValue: number;
performancePercentage: number;
periodicGroupId?: string;
avgBuyIn: number;
}
export interface DateRange {
startDate: string;
endDate: string;
}

View file

@ -0,0 +1,95 @@
import { addDays, isAfter, isBefore, isSameDay } from "date-fns";
import { Asset, Investment } from "../../types";
export interface PeriodicSettings {
startDate: string;
dayOfMonth: number;
interval: number;
amount: number;
dynamic?: {
type: 'percentage' | 'fixed';
value: number;
yearInterval: number;
};
}
export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice: number) => {
let totalShares = 0;
const buyIns:number[] = [];
// Calculate shares for each investment up to the given date
for(const investment of asset.investments) {
const invDate = new Date(investment.date!);
if (isAfter(invDate, date) || isSameDay(invDate, date)) continue;
// Find price at investment date
const investmentPrice = asset.historicalData.find(
(data) => data.date === investment.date
)?.price || 0;
// if no investment price found, use the previous price
const previousInvestmentPrice = investmentPrice || asset.historicalData
.filter(({ date }) => isAfter(new Date(date), invDate) || isSameDay(new Date(date), invDate))
.find(({ price }) => price !== 0)?.price || 0;
const investmentPriceToUse = investmentPrice || previousInvestmentPrice || asset.historicalData
.filter(({ date }) => isBefore(new Date(date), invDate) || isSameDay(new Date(date), invDate))
.reverse()
.find(({ price }) => price !== 0)?.price || 0;
if (investmentPriceToUse > 0) {
totalShares += investment.amount / investmentPriceToUse;
buyIns.push(investmentPriceToUse);
}
}
// Return current value of all shares
return {
investedValue: totalShares * currentPrice,
avgBuyIn: buyIns.reduce((a, b) => a + b, 0) / buyIns.length,
}
};
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: Date, assetId: string): Investment[] => {
const investments: Investment[] = [];
let currentDate = new Date(settings.startDate);
let currentAmount = settings.amount;
const periodicGroupId = crypto.randomUUID();
while (isBefore(currentDate, endDate)) {
if (currentDate.getDate() === settings.dayOfMonth) {
// Handle dynamic increases if configured
if (settings.dynamic) {
const yearsSinceStart =
(currentDate.getTime() - new Date(settings.startDate).getTime()) /
(1000 * 60 * 60 * 24 * 365);
if (yearsSinceStart >= settings.dynamic.yearInterval) {
if (settings.dynamic.type === 'percentage') {
currentAmount *= (1 + settings.dynamic.value / 100);
} else {
currentAmount += settings.dynamic.value;
}
}
}
// Create investment for this date
investments.push({
id: crypto.randomUUID(),
type: 'periodic',
amount: currentAmount,
date: currentDate.toISOString().split('T')[0],
periodicGroupId,
assetId
});
// Move to next interval
currentDate = addDays(currentDate, settings.interval);
} else {
// Move to next day if not the investment day
currentDate = addDays(currentDate, 1);
}
}
return investments;
};

View file

@ -0,0 +1,105 @@
import { isAfter, isBefore } from "date-fns";
import { Asset } from "../../types";
export interface InvestmentPerformance {
id: string;
assetName: string;
date: string;
investedAmount: number;
investedAtPrice: number;
currentValue: number;
performancePercentage: number;
}
export interface PortfolioPerformance {
investments: InvestmentPerformance[];
summary: {
totalInvested: number;
currentValue: number;
performancePercentage: number;
ttworValue: number;
ttworPercentage: number;
};
}
export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerformance => {
const investments: InvestmentPerformance[] = [];
let totalInvested = 0;
let totalCurrentValue = 0;
// TTWOR Berechnung
const firstDayPrices: Record<string, number> = {};
const currentPrices: Record<string, number> = {};
const investedPerAsset: Record<string, number> = {};
// Sammle erste und letzte Preise für jedes Asset
for(const asset of assets) {
firstDayPrices[asset.id] = asset.historicalData[0]?.price || 0;
currentPrices[asset.id] = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
investedPerAsset[asset.id] = asset.investments.reduce((sum, inv) => sum + inv.amount, 0);
}
// Berechne TTWOR
const ttworValue = Object.entries(investedPerAsset).reduce((acc, [assetId, invested]) => {
if (firstDayPrices[assetId] && currentPrices[assetId] && firstDayPrices[assetId] > 0) {
const shares = invested / firstDayPrices[assetId];
return acc + (shares * currentPrices[assetId]);
}
return acc;
}, 0);
// Normale Performance-Berechnungen...
for(const asset of assets) {
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
for(const investment of asset.investments) {
const investmentPrice = asset.historicalData.find(
(data) => data.date === investment.date
)?.price || 0;
const previousPrice = investmentPrice || asset.historicalData.filter(
(data) => isBefore(new Date(data.date), new Date(investment.date!))
).reverse().find((v) => v.price !== 0)?.price || 0;
const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter(
(data) => isAfter(new Date(data.date), new Date(investment.date!))
).find((v) => v.price !== 0)?.price || 0;
const shares = buyInPrice > 0 ? investment.amount / buyInPrice : 0;
const currentValue = shares * currentPrice;
investments.push({
id: investment.id,
assetName: asset.name,
date: investment.date!,
investedAmount: investment.amount,
investedAtPrice: buyInPrice,
currentValue,
performancePercentage: investment.amount > 0
? (((currentValue - investment.amount) / investment.amount)) * 100
: 0,
});
totalInvested += investment.amount;
totalCurrentValue += currentValue;
}
}
const ttworPercentage = totalInvested > 0
? ((ttworValue - totalInvested) / totalInvested) * 100
: 0;
return {
investments,
summary: {
totalInvested,
currentValue: totalCurrentValue,
performancePercentage: totalInvested > 0
? ((totalCurrentValue - totalInvested) / totalInvested) * 100
: 0,
ttworValue,
ttworPercentage,
},
};
};

View file

@ -0,0 +1,79 @@
import { addDays, isAfter, isBefore } from "date-fns";
import { Asset, DateRange } from "../../types";
import { calculateAssetValueAtDate } from "./assetValue";
type DayData = {
date: string;
total: number;
invested: number;
percentageChange: number;
/* Current price of asset */
assets: { [key: string]: number };
};
export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => {
const { startDate, endDate } = dateRange;
const data: DayData[] = [];
let currentDate = new Date(startDate);
const end = new Date(endDate);
const beforeValue: { [assetId: string]: number } = {};
while (isBefore(currentDate, end)) {
const dayData: DayData = {
date: currentDate.toISOString().split('T')[0],
total: 0,
invested: 0,
percentageChange: 0,
assets: {},
};
// this should contain the percentage gain of all investments till now
const pPercents: number[] = [];
for(const asset of assets) {
// calculate the invested kapital
for(const investment of asset.investments) {
if (!isAfter(new Date(investment.date!), currentDate)) {
dayData.invested += investment.amount;
}
}
// Get historical price for the asset
const currentValueOfAsset = asset.historicalData.find(
(data) => data.date === dayData.date
)?.price || beforeValue[asset.id];
beforeValue[asset.id] = currentValueOfAsset;
if (currentValueOfAsset !== undefined) {
const { investedValue, avgBuyIn } = calculateAssetValueAtDate(
asset,
currentDate,
currentValueOfAsset
);
dayData.total += investedValue || 0;
dayData.assets[asset.id] = currentValueOfAsset;
const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
if(!Number.isNaN(percent)) pPercents.push(percent);
}
}
// 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;
} else {
dayData.percentageChange = 0;
}
currentDate = addDays(currentDate, 1);
data.push(dayData);
}
// Filter out days with incomplete asset data
return data.filter(
(dayData) => !Object.values(dayData.assets).some((value) => value === 0)
);
};

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

10
tailwind.config.js Normal file
View file

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [
],
};

24
tsconfig.app.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

24
vite.config.ts Normal file
View file

@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
server: {
proxy: {
'/yahoo': {
target: 'https://query1.finance.yahoo.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/yahoo/, ''),
headers: {
'Origin': 'https://finance.yahoo.com'
}
}
}
},
base: process.env.VITE_BASE_URL || '/',
});