mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-07 12:00:35 +02:00
Initial commit: Portfolio Simulator Implementation
This commit is contained in:
commit
0912dbdb97
31 changed files with 6492 additions and 0 deletions
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
8
.bolt/prompt
Normal file
8
.bolt/prompt
Normal 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
53
.github/workflows/deploy.yml
vendored
Normal 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
24
.gitignore
vendored
Normal 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
28
eslint.config.js
Normal 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
13
index.html
Normal 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
4472
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
package.json
Normal file
38
package.json
Normal 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
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
57
src/App.tsx
Normal file
57
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
118
src/components/AddAssetModal.tsx
Normal file
118
src/components/AddAssetModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
41
src/components/DateRangePicker.tsx
Normal file
41
src/components/DateRangePicker.tsx
Normal 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>
|
||||
);
|
||||
};
|
79
src/components/EditInvestmentModal.tsx
Normal file
79
src/components/EditInvestmentModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
262
src/components/InvestmentForm.tsx
Normal file
262
src/components/InvestmentForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
323
src/components/PortfolioChart.tsx
Normal file
323
src/components/PortfolioChart.tsx
Normal 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>
|
||||
);
|
||||
};
|
225
src/components/PortfolioTable.tsx
Normal file
225
src/components/PortfolioTable.tsx
Normal 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
76
src/index.css
Normal 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
15
src/main.tsx
Normal 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>
|
||||
);
|
46
src/providers/DarkModeProvider.tsx
Normal file
46
src/providers/DarkModeProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
108
src/services/yahooFinanceService.ts
Normal file
108
src/services/yahooFinanceService.ts
Normal 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 [];
|
||||
}
|
||||
};
|
80
src/store/portfolioStore.ts
Normal file
80
src/store/portfolioStore.ts
Normal 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
50
src/types/index.ts
Normal 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;
|
||||
}
|
95
src/utils/calculations/assetValue.ts
Normal file
95
src/utils/calculations/assetValue.ts
Normal 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;
|
||||
};
|
105
src/utils/calculations/performance.ts
Normal file
105
src/utils/calculations/performance.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
79
src/utils/calculations/portfolioValue.ts
Normal file
79
src/utils/calculations/portfolioValue.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
10
tailwind.config.js
Normal file
10
tailwind.config.js
Normal 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
24
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal 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
24
vite.config.ts
Normal 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 || '/',
|
||||
});
|
Loading…
Add table
Reference in a new issue