mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-03 21:50:35 +02:00
added stock screener
This commit is contained in:
parent
1adcad1855
commit
1a89ea6215
52 changed files with 10881 additions and 10117 deletions
108
.github/workflows/deploy.yml
vendored
108
.github/workflows/deploy.yml
vendored
|
@ -1,54 +1,54 @@
|
|||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
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: Create 404.html
|
||||
run: cp dist/index.html dist/404.html
|
||||
|
||||
- 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
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
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: Create 404.html
|
||||
run: cp dist/index.html dist/404.html
|
||||
|
||||
- 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
|
||||
|
|
48
.gitignore
vendored
48
.gitignore
vendored
|
@ -1,24 +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?
|
||||
# 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?
|
||||
|
|
30
Dockerfile
30
Dockerfile
|
@ -1,15 +1,15 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
# Build stage
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
42
LICENSE
42
LICENSE
|
@ -1,21 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Chrissy8283 (aka Tomato6966)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Chrissy8283 (aka Tomato6966)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
168
README.md
168
README.md
|
@ -1,84 +1,84 @@
|
|||
# Investment Portfolio Simulator
|
||||
|
||||
A modern web application for simulating and tracking investment portfolios with real-time data. Built with React, TypeScript, and Tailwind CSS.
|
||||
|
||||
Why this Project?
|
||||
- I wanted to see how my portfolio would perform if I had invested in something else, and thus with savings plan(s)
|
||||
- The main issue with other saving-plan calculators is, that they calculate based on the p.a. performance, i wanted further insights however, and thus this projected was created.
|
||||
- It allows you to see how single-investments and savings plans would have performed, and how they would have affected your portfolio.
|
||||
- There are multiple indicators and design choices made:
|
||||
- TTWOR (Time Travel Without Risk) calculations
|
||||
- Average Portfolio Performance
|
||||
- Portfolio Performance & Value
|
||||
- All assets (except the TTWOR and Portfolio-Value) are scaled by percentage of their price. Thus their referenced, scale is on the right. The referenced scale on the left is only for the portfolio-value
|
||||
|
||||
https://github.com/user-attachments/assets/4507e102-8dfb-4614-b2ba-938e20e3d97b
|
||||
|
||||
## Features
|
||||
|
||||
- 📈 Real-time stock data from Yahoo Finance
|
||||
- 💰 Track multiple assets and investments
|
||||
- 📊 Interactive charts with performance visualization
|
||||
- 🌓 Dark/Light mode support
|
||||
- 📱 Responsive design
|
||||
- *Mobile friendly*
|
||||
- 📅 Historical data analysis
|
||||
- *The portfolio is fully based on real-historical data, with configurable timeranges*
|
||||
- 💹 TTWOR (Time Travel Without Risk) calculations
|
||||
- *Including metrics for TTWOR*
|
||||
- 🔄 Support for one-time and periodic investments
|
||||
- *You can config your dream-portfolio by one time and periodic investments easily*
|
||||
- 📊 Detailed performance metrics
|
||||
- *See all needed performance metrics in one place*
|
||||
- 📅 Future Projection with Withdrawal Analysis and Sustainability Analysis
|
||||
- *Generate a future projection based on the current portfolio performance, with a withdrawal analysis and sustainability analysis + calculator*
|
||||
- *Including with best, worst and average case scenarios*
|
||||
- 📊 Savings Plan Performance Overview Tables
|
||||
- *See the performance of your savings plans if you have multiple assets to compare them*
|
||||
- 📄 Export to PDF
|
||||
- *Export the entire portfolio Overview to a PDF, including Future Projections of 10, 15, 20, 30 and 40 years*
|
||||
- 📄 Export to CSV Tables
|
||||
- *Export all available tables to CSV*
|
||||
- See the asset performance p.a. as well as of the portfolio
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Vite@6
|
||||
- Recharts
|
||||
- date-fns
|
||||
- Lucide Icons
|
||||
|
||||
## Self Hosting
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js & npm 20 or higher
|
||||
|
||||
### Local Development
|
||||
|
||||
1. Clone the repository
|
||||
2. Run `npm install`
|
||||
3. Run `npm run dev` -> developer preview
|
||||
- Run `npm run build` -> build for production (dist folder) (you can then launch it with dockerfile or with a static file server like nginx)
|
||||
- Run `npm run preview` -> preview the production build (dist folder)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### Credits:
|
||||
|
||||
> Thanks to [yahoofinance](https://finance.yahoo.com/) for the stock data.
|
||||
|
||||
|
||||
- **15.01.2025:** Increased Performance of entire Site by utilizing Maps
|
||||
# Investment Portfolio Simulator
|
||||
|
||||
A modern web application for simulating and tracking investment portfolios with real-time data. Built with React, TypeScript, and Tailwind CSS.
|
||||
|
||||
Why this Project?
|
||||
- I wanted to see how my portfolio would perform if I had invested in something else, and thus with savings plan(s)
|
||||
- The main issue with other saving-plan calculators is, that they calculate based on the p.a. performance, i wanted further insights however, and thus this projected was created.
|
||||
- It allows you to see how single-investments and savings plans would have performed, and how they would have affected your portfolio.
|
||||
- There are multiple indicators and design choices made:
|
||||
- TTWOR (Time Travel Without Risk) calculations
|
||||
- Average Portfolio Performance
|
||||
- Portfolio Performance & Value
|
||||
- All assets (except the TTWOR and Portfolio-Value) are scaled by percentage of their price. Thus their referenced, scale is on the right. The referenced scale on the left is only for the portfolio-value
|
||||
|
||||
https://github.com/user-attachments/assets/4507e102-8dfb-4614-b2ba-938e20e3d97b
|
||||
|
||||
## Features
|
||||
|
||||
- 📈 Real-time stock data from Yahoo Finance
|
||||
- 💰 Track multiple assets and investments
|
||||
- 📊 Interactive charts with performance visualization
|
||||
- 🌓 Dark/Light mode support
|
||||
- 📱 Responsive design
|
||||
- *Mobile friendly*
|
||||
- 📅 Historical data analysis
|
||||
- *The portfolio is fully based on real-historical data, with configurable timeranges*
|
||||
- 💹 TTWOR (Time Travel Without Risk) calculations
|
||||
- *Including metrics for TTWOR*
|
||||
- 🔄 Support for one-time and periodic investments
|
||||
- *You can config your dream-portfolio by one time and periodic investments easily*
|
||||
- 📊 Detailed performance metrics
|
||||
- *See all needed performance metrics in one place*
|
||||
- 📅 Future Projection with Withdrawal Analysis and Sustainability Analysis
|
||||
- *Generate a future projection based on the current portfolio performance, with a withdrawal analysis and sustainability analysis + calculator*
|
||||
- *Including with best, worst and average case scenarios*
|
||||
- 📊 Savings Plan Performance Overview Tables
|
||||
- *See the performance of your savings plans if you have multiple assets to compare them*
|
||||
- 📄 Export to PDF
|
||||
- *Export the entire portfolio Overview to a PDF, including Future Projections of 10, 15, 20, 30 and 40 years*
|
||||
- 📄 Export to CSV Tables
|
||||
- *Export all available tables to CSV*
|
||||
- See the asset performance p.a. as well as of the portfolio
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Vite@6
|
||||
- Recharts
|
||||
- date-fns
|
||||
- Lucide Icons
|
||||
|
||||
## Self Hosting
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js & npm 20 or higher
|
||||
|
||||
### Local Development
|
||||
|
||||
1. Clone the repository
|
||||
2. Run `npm install`
|
||||
3. Run `npm run dev` -> developer preview
|
||||
- Run `npm run build` -> build for production (dist folder) (you can then launch it with dockerfile or with a static file server like nginx)
|
||||
- Run `npm run preview` -> preview the production build (dist folder)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
### Credits:
|
||||
|
||||
> Thanks to [yahoofinance](https://finance.yahoo.com/) for the stock data.
|
||||
|
||||
|
||||
- **15.01.2025:** Increased Performance of entire Site by utilizing Maps
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
|
@ -1,29 +1,29 @@
|
|||
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,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
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,
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
90
index.html
90
index.html
|
@ -1,45 +1,45 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/public/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/public/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/public/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/public/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/public/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/public/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/public/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/public/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/public/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/public/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/public/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/public/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/public/favicon-16x16.png">
|
||||
<link rel="icon" type="image/x-icon" href="/public/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Investment Portfolio Simulator</title>
|
||||
<meta name="title" content="Investment Portfolio Simulator" />
|
||||
<meta name="description" content="Advanced investment portfolio simulator with real-time data, TTWOR calculations, and future projections. Track investments, analyze performance, and plan withdrawals." />
|
||||
<meta name="keywords" content="investment simulator, portfolio tracker, TTWOR calculator, investment planning, stock portfolio, financial planning, withdrawal calculator" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://tomato6966.github.io/investment-portfolio-simulator/" />
|
||||
<meta property="og:title" content="Investment Portfolio Simulator" />
|
||||
<meta property="og:description" content="Advanced investment portfolio simulator with real-time data, TTWOR calculations, and future projections. Track investments, analyze performance, and plan withdrawals." />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://tomato6966.github.io/investment-portfolio-simulator/" />
|
||||
<meta property="twitter:title" content="Investment Portfolio Simulator" />
|
||||
<meta property="twitter:description" content="Advanced investment portfolio simulator with real-time data, TTWOR calculations, and future projections. Track investments, analyze performance, and plan withdrawals." />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<meta name="author" content="Tomato6696 (chrissy8283)" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="https://tomato6966.github.io/investment-portfolio-simulator/" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/public/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/public/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/public/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/public/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/public/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/public/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/public/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/public/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/public/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/public/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/public/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/public/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/public/favicon-16x16.png">
|
||||
<link rel="icon" type="image/x-icon" href="/public/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Investment Portfolio Simulator</title>
|
||||
<meta name="title" content="Investment Portfolio Simulator" />
|
||||
<meta name="description" content="Advanced investment portfolio simulator with real-time data, TTWOR calculations, and future projections. Track investments, analyze performance, and plan withdrawals." />
|
||||
<meta name="keywords" content="investment simulator, portfolio tracker, TTWOR calculator, investment planning, stock portfolio, financial planning, withdrawal calculator" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://tomato6966.github.io/investment-portfolio-simulator/" />
|
||||
<meta property="og:title" content="Investment Portfolio Simulator" />
|
||||
<meta property="og:description" content="Advanced investment portfolio simulator with real-time data, TTWOR calculations, and future projections. Track investments, analyze performance, and plan withdrawals." />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://tomato6966.github.io/investment-portfolio-simulator/" />
|
||||
<meta property="twitter:title" content="Investment Portfolio Simulator" />
|
||||
<meta property="twitter:description" content="Advanced investment portfolio simulator with real-time data, TTWOR calculations, and future projections. Track investments, analyze performance, and plan withdrawals." />
|
||||
<meta name="theme-color" content="#4f46e5" />
|
||||
<meta name="author" content="Tomato6696 (chrissy8283)" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="canonical" href="https://tomato6966.github.io/investment-portfolio-simulator/" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -1,41 +1,41 @@
|
|||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
34
nginx.conf
34
nginx.conf
|
@ -1,17 +1,17 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Support for SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Support for SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
}
|
||||
|
|
9484
package-lock.json
generated
9484
package-lock.json
generated
File diff suppressed because it is too large
Load diff
82
package.json
82
package.json
|
@ -1,41 +1,41 @@
|
|||
{
|
||||
"name": "investment-portfolio-tracker",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "investment-portfolio-tracker",
|
||||
"private": true,
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"recharts": "^2.15.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
65
src/App.tsx
65
src/App.tsx
|
@ -1,26 +1,39 @@
|
|||
import { lazy, Suspense, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { AppShell } from "./components/Landing/AppShell";
|
||||
import { LoadingPlaceholder } from "./components/utils/LoadingPlaceholder";
|
||||
import { PortfolioProvider } from "./providers/PortfolioProvider";
|
||||
|
||||
const MainContent = lazy(() => import("./components/Landing/MainContent"));
|
||||
|
||||
export default function App() {
|
||||
const [isAddingAsset, setIsAddingAsset] = useState(false);
|
||||
|
||||
return (
|
||||
<PortfolioProvider>
|
||||
<AppShell onAddAsset={() => setIsAddingAsset(true)}>
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-screen" />}>
|
||||
<MainContent
|
||||
isAddingAsset={isAddingAsset}
|
||||
setIsAddingAsset={setIsAddingAsset}
|
||||
/>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<Toaster position="bottom-right" />
|
||||
</PortfolioProvider>
|
||||
);
|
||||
}
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import { AppShell } from "./components/Landing/AppShell";
|
||||
import { LoadingPlaceholder } from "./components/utils/LoadingPlaceholder";
|
||||
import StockExplorer from "./pages/StockExplorer";
|
||||
import { PortfolioProvider } from "./providers/PortfolioProvider";
|
||||
|
||||
const MainContent = lazy(() => import("./components/Landing/MainContent"));
|
||||
|
||||
function Root() {
|
||||
const [isAddingAsset, setIsAddingAsset] = useState(false);
|
||||
|
||||
return (
|
||||
<PortfolioProvider>
|
||||
<AppShell onAddAsset={() => setIsAddingAsset(true)}>
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-screen" />}>
|
||||
<MainContent
|
||||
isAddingAsset={isAddingAsset}
|
||||
setIsAddingAsset={setIsAddingAsset}
|
||||
/>
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<Toaster position="bottom-right" />
|
||||
</PortfolioProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Export the routes configuration that will be used in main.tsx
|
||||
export default [
|
||||
{
|
||||
path: '/',
|
||||
element: <Root />
|
||||
},
|
||||
{
|
||||
path: '/explore',
|
||||
element: <StockExplorer />
|
||||
}
|
||||
];
|
||||
|
|
|
@ -1,129 +1,129 @@
|
|||
import { X } from "lucide-react";
|
||||
import { memo, useEffect } from "react";
|
||||
import {
|
||||
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from "recharts";
|
||||
|
||||
interface AssetPerformanceModalProps {
|
||||
assetName: string;
|
||||
performances: { year: number; percentage: number; price?: number }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AssetPerformanceModal = memo(({ assetName, performances, onClose }: AssetPerformanceModalProps) => {
|
||||
const sortedPerformances = [...performances].sort((a, b) => a.year - b.year);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const CustomizedDot = (props: any) => {
|
||||
const { cx, cy, payload } = props;
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={payload.percentage >= 0 ? '#22c55e' : '#ef4444'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 md:flex md:items-center md:justify-center">
|
||||
<div className="h-full md:h-auto w-full md:w-[80vw] bg-white dark:bg-slate-800 md:rounded-lg overflow-hidden">
|
||||
{/* Header - Fixed */}
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-slate-800 p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold dark:text-gray-300">{assetName} - Yearly Performance</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
|
||||
>
|
||||
<X className="w-6 h-6 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="overflow-y-auto h-[calc(100vh-64px)] md:h-[calc(80vh-64px)] p-6">
|
||||
{/* Chart */}
|
||||
<div className="h-[400px] mb-6">
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={sortedPerformances}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="year" />
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => `€${value.toFixed(2)}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Performance') return [`${value.toFixed(2)}%`, name];
|
||||
return [`€${value.toFixed(2)}`, name];
|
||||
}}
|
||||
labelFormatter={(year) => `Year ${year}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="percentage"
|
||||
name="Performance"
|
||||
stroke="url(#colorGradient)"
|
||||
dot={<CustomizedDot />}
|
||||
strokeWidth={2}
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="price"
|
||||
name="Price"
|
||||
stroke="#666"
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
yAxisId="right"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" />
|
||||
<stop offset="100%" stopColor="#ef4444" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Performance Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 dark:text-gray-300">
|
||||
{sortedPerformances.map(({ year, percentage, price }) => (
|
||||
<div
|
||||
key={year}
|
||||
className={`p-3 rounded-lg ${
|
||||
percentage >= 0 ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{year}</div>
|
||||
<div className={`text-lg font-bold ${
|
||||
percentage >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{percentage.toFixed(2)}%
|
||||
</div>
|
||||
{price && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
€{price.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
import { X } from "lucide-react";
|
||||
import { memo, useEffect } from "react";
|
||||
import {
|
||||
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from "recharts";
|
||||
|
||||
interface AssetPerformanceModalProps {
|
||||
assetName: string;
|
||||
performances: { year: number; percentage: number; price?: number }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const AssetPerformanceModal = memo(({ assetName, performances, onClose }: AssetPerformanceModalProps) => {
|
||||
const sortedPerformances = [...performances].sort((a, b) => a.year - b.year);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const CustomizedDot = (props: any) => {
|
||||
const { cx, cy, payload } = props;
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={payload.percentage >= 0 ? '#22c55e' : '#ef4444'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 md:flex md:items-center md:justify-center">
|
||||
<div className="h-full md:h-auto w-full md:w-[80vw] bg-white dark:bg-slate-800 md:rounded-lg overflow-hidden">
|
||||
{/* Header - Fixed */}
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-slate-800 p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold dark:text-gray-300">{assetName} - Yearly Performance</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
|
||||
>
|
||||
<X className="w-6 h-6 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="overflow-y-auto h-[calc(100vh-64px)] md:h-[calc(80vh-64px)] p-6">
|
||||
{/* Chart */}
|
||||
<div className="h-[400px] mb-6">
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={sortedPerformances}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="year" />
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => `€${value.toFixed(2)}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Performance') return [`${value.toFixed(2)}%`, name];
|
||||
return [`€${value.toFixed(2)}`, name];
|
||||
}}
|
||||
labelFormatter={(year) => `Year ${year}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="percentage"
|
||||
name="Performance"
|
||||
stroke="url(#colorGradient)"
|
||||
dot={<CustomizedDot />}
|
||||
strokeWidth={2}
|
||||
yAxisId="left"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="price"
|
||||
name="Price"
|
||||
stroke="#666"
|
||||
strokeDasharray="5 5"
|
||||
dot={false}
|
||||
yAxisId="right"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" />
|
||||
<stop offset="100%" stopColor="#ef4444" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Performance Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 dark:text-gray-300">
|
||||
{sortedPerformances.map(({ year, percentage, price }) => (
|
||||
<div
|
||||
key={year}
|
||||
className={`p-3 rounded-lg ${
|
||||
percentage >= 0 ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{year}</div>
|
||||
<div className={`text-lg font-bold ${
|
||||
percentage >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{percentage.toFixed(2)}%
|
||||
</div>
|
||||
{price && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
€{price.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,184 +1,187 @@
|
|||
import { format } from "date-fns";
|
||||
import { Maximize2, RefreshCcw } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import {
|
||||
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from "recharts";
|
||||
|
||||
import { Asset, DateRange } from "../../types";
|
||||
import { DateRangePicker } from "../utils/DateRangePicker";
|
||||
import { ChartLegend } from "./ChartLegend";
|
||||
|
||||
interface ChartContentProps {
|
||||
dateRange: DateRange;
|
||||
handleUpdateDateRange: (range: DateRange) => void;
|
||||
handleReRender: () => void;
|
||||
isFullscreen: boolean;
|
||||
setIsFullscreen: (value: boolean) => void;
|
||||
renderKey: number;
|
||||
isDarkMode: boolean;
|
||||
hideAssets: boolean;
|
||||
hiddenAssets: Set<string>;
|
||||
processedData: any[];
|
||||
assets: Asset[];
|
||||
assetColors: Record<string, string>;
|
||||
toggleAsset: (assetId: string) => void;
|
||||
toggleAllAssets: () => void;
|
||||
}
|
||||
|
||||
export const ChartContent = memo(({
|
||||
dateRange,
|
||||
handleUpdateDateRange,
|
||||
handleReRender,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
renderKey,
|
||||
isDarkMode,
|
||||
hideAssets,
|
||||
hiddenAssets,
|
||||
processedData,
|
||||
assets,
|
||||
assetColors,
|
||||
toggleAsset,
|
||||
toggleAllAssets
|
||||
}: ChartContentProps) => (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4 p-5">
|
||||
<DateRangePicker
|
||||
startDate={dateRange.startDate}
|
||||
endDate={dateRange.endDate}
|
||||
onStartDateChange={(date) => handleUpdateDateRange({ ...dateRange, startDate: date })}
|
||||
onEndDateChange={(date) => handleUpdateDateRange({ ...dateRange, endDate: date })}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleReRender}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded ml-2 hover:text-blue-500"
|
||||
>
|
||||
<RefreshCcw className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded hover:text-blue-500"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"} key={renderKey}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={processedData} className="p-3">
|
||||
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||
<XAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
dataKey="date"
|
||||
tickFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
yAxisId="left"
|
||||
tickFormatter={(value) => `${value.toFixed(2)}€`}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: isDarkMode ? '#1e293b' : '#fff',
|
||||
border: 'none',
|
||||
color: isDarkMode ? '#d1d5d1' : '#000000',
|
||||
boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
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), 'dd.MM.yyyy')}
|
||||
/>
|
||||
<Legend content={<ChartLegend
|
||||
payload={assets}
|
||||
hideAssets={hideAssets}
|
||||
hiddenAssets={hiddenAssets}
|
||||
toggleAsset={toggleAsset}
|
||||
toggleAllAssets={toggleAllAssets}
|
||||
/>} />
|
||||
<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) => (
|
||||
<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>
|
||||
<p className="text-xs mt-2 text-gray-500 italic">
|
||||
**Note: The % is based on daily weighted average data, thus the percentages might alter slightly.
|
||||
</p>
|
||||
</>
|
||||
));
|
||||
import { format } from "date-fns";
|
||||
import { Maximize2, RefreshCcw } from "lucide-react";
|
||||
import {
|
||||
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from "recharts";
|
||||
|
||||
import { Asset, DateRange } from "../../types";
|
||||
import { DateRangePicker } from "../utils/DateRangePicker";
|
||||
import { ChartLegend } from "./ChartLegend";
|
||||
|
||||
interface ChartContentProps {
|
||||
dateRange: DateRange;
|
||||
handleUpdateDateRange: (range: DateRange) => void;
|
||||
handleReRender: () => void;
|
||||
isFullscreen: boolean;
|
||||
setIsFullscreen: (value: boolean) => void;
|
||||
renderKey: number;
|
||||
isDarkMode: boolean;
|
||||
hideAssets: boolean;
|
||||
hiddenAssets: Set<string>;
|
||||
processedData: any[];
|
||||
assets: Asset[];
|
||||
assetColors: Record<string, string>;
|
||||
toggleAsset: (assetId: string) => void;
|
||||
toggleAllAssets: () => void;
|
||||
removeAsset?: (assetId: string) => void;
|
||||
}
|
||||
|
||||
export const ChartContent = ({
|
||||
dateRange,
|
||||
handleUpdateDateRange,
|
||||
handleReRender,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
renderKey,
|
||||
isDarkMode,
|
||||
hideAssets,
|
||||
hiddenAssets,
|
||||
processedData,
|
||||
assets,
|
||||
assetColors,
|
||||
toggleAsset,
|
||||
toggleAllAssets,
|
||||
removeAsset
|
||||
}: ChartContentProps) => (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-4 p-5">
|
||||
<DateRangePicker
|
||||
startDate={dateRange.startDate}
|
||||
endDate={dateRange.endDate}
|
||||
onStartDateChange={(date) => handleUpdateDateRange({ ...dateRange, startDate: date })}
|
||||
onEndDateChange={(date) => handleUpdateDateRange({ ...dateRange, endDate: date })}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleReRender}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded ml-2 hover:text-blue-500"
|
||||
>
|
||||
<RefreshCcw className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded hover:text-blue-500"
|
||||
>
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"} key={renderKey}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={processedData} className="p-3">
|
||||
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||
<XAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
dataKey="date"
|
||||
tickFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
yAxisId="left"
|
||||
tickFormatter={(value) => `${value.toFixed(2)}€`}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: isDarkMode ? '#1e293b' : '#fff',
|
||||
border: 'none',
|
||||
color: isDarkMode ? '#d1d5d1' : '#000000',
|
||||
boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
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), 'dd.MM.yyyy')}
|
||||
/>
|
||||
<Legend content={<ChartLegend
|
||||
payload={assets}
|
||||
hideAssets={hideAssets}
|
||||
hiddenAssets={hiddenAssets}
|
||||
toggleAsset={toggleAsset}
|
||||
toggleAllAssets={toggleAllAssets}
|
||||
removeAsset={removeAsset}
|
||||
/>} />
|
||||
<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) => (
|
||||
<Line
|
||||
key={asset.id}
|
||||
type="basis"
|
||||
hide={hideAssets || hiddenAssets.has(asset.id)}
|
||||
dataKey={`${asset.id}_percent`}
|
||||
name={`${asset.name} (%)`}
|
||||
stroke={assetColors[asset.id] || "red"}
|
||||
dot={false}
|
||||
yAxisId="right"
|
||||
connectNulls={true}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
<p className="text-xs mt-2 text-gray-500 italic">
|
||||
**Note: The % is based on daily weighted average data, thus the percentages might alter slightly.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,66 +1,83 @@
|
|||
import { BarChart2, Eye, EyeOff } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ChartLegendProps {
|
||||
payload: any[];
|
||||
hideAssets: boolean;
|
||||
hiddenAssets: Set<string>;
|
||||
toggleAsset: (assetId: string) => void;
|
||||
toggleAllAssets: () => void;
|
||||
}
|
||||
|
||||
export const ChartLegend = memo(({ payload, hideAssets, hiddenAssets, toggleAsset, toggleAllAssets }: ChartLegendProps) => {
|
||||
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>
|
||||
);
|
||||
});
|
||||
import { BarChart2, Eye, EyeOff, Trash2 } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
|
||||
interface ChartLegendProps {
|
||||
payload: any[];
|
||||
hideAssets: boolean;
|
||||
hiddenAssets: Set<string>;
|
||||
toggleAsset: (assetId: string) => void;
|
||||
toggleAllAssets: () => void;
|
||||
removeAsset?: (assetId: string) => void;
|
||||
}
|
||||
|
||||
export const ChartLegend = memo(({ payload, hideAssets, hiddenAssets, toggleAsset, toggleAllAssets, removeAsset }: ChartLegendProps) => {
|
||||
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 (
|
||||
<div key={`asset-${index}`} className="flex items-center">
|
||||
<button
|
||||
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>
|
||||
|
||||
{removeAsset && !['total', 'invested', 'percentageChange', 'ttwor'].includes(assetId) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to remove ${entry.value.replace(' (%)', '')}?`)) {
|
||||
removeAsset(assetId);
|
||||
}
|
||||
}}
|
||||
className="p-1 ml-1 text-red-500 hover:bg-red-100 dark:hover:bg-red-900/30 rounded"
|
||||
title="Remove asset"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,114 +1,114 @@
|
|||
import { X } from "lucide-react";
|
||||
import { memo, useEffect } from "react";
|
||||
import {
|
||||
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from "recharts";
|
||||
|
||||
interface PortfolioPerformanceModalProps {
|
||||
performances: { year: number; percentage: number; }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PortfolioPerformanceModal = memo(({ performances, onClose }: PortfolioPerformanceModalProps) => {
|
||||
const sortedPerformances = [...performances].sort((a, b) => a.year - b.year);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const CustomizedDot = (props: any) => {
|
||||
const { cx, cy, payload } = props;
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={payload.percentage >= 0 ? '#22c55e' : '#ef4444'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 md:flex md:items-center md:justify-center">
|
||||
<div className="h-full md:h-auto w-full md:w-[80vw] bg-white dark:bg-slate-800 md:rounded-lg overflow-hidden">
|
||||
{/* Header - Fixed */}
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-slate-800 p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold dark:text-gray-300">Portfolio Performance History</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
|
||||
>
|
||||
<X className="w-6 h-6 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="overflow-y-auto h-[calc(100vh-64px)] md:h-[calc(80vh-64px)] p-6">
|
||||
{/* Chart */}
|
||||
<div className="h-[400px] mb-6">
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={sortedPerformances}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="year" />
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Performance') return [`${value.toFixed(2)}%`, name];
|
||||
return [`€${value.toLocaleString()}`, 'Portfolio Value'];
|
||||
}}
|
||||
labelFormatter={(year) => `Year ${year}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="percentage"
|
||||
name="Performance"
|
||||
stroke="url(#colorGradient)"
|
||||
dot={<CustomizedDot />}
|
||||
strokeWidth={2}
|
||||
yAxisId="left"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" />
|
||||
<stop offset="100%" stopColor="#ef4444" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Performance Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 dark:text-gray-300">
|
||||
{sortedPerformances.map(({ year, percentage }) => (
|
||||
<div
|
||||
key={year}
|
||||
className={`p-3 rounded-lg ${
|
||||
percentage >= 0 ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{year}</div>
|
||||
<div className={`text-lg font-bold ${
|
||||
percentage >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{percentage.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
import { X } from "lucide-react";
|
||||
import { memo, useEffect } from "react";
|
||||
import {
|
||||
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from "recharts";
|
||||
|
||||
interface PortfolioPerformanceModalProps {
|
||||
performances: { year: number; percentage: number; }[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PortfolioPerformanceModal = memo(({ performances, onClose }: PortfolioPerformanceModalProps) => {
|
||||
const sortedPerformances = [...performances].sort((a, b) => a.year - b.year);
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const CustomizedDot = (props: any) => {
|
||||
const { cx, cy, payload } = props;
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={payload.percentage >= 0 ? '#22c55e' : '#ef4444'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 md:flex md:items-center md:justify-center">
|
||||
<div className="h-full md:h-auto w-full md:w-[80vw] bg-white dark:bg-slate-800 md:rounded-lg overflow-hidden">
|
||||
{/* Header - Fixed */}
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-slate-800 p-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold dark:text-gray-300">Portfolio Performance History</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-slate-700 rounded"
|
||||
>
|
||||
<X className="w-6 h-6 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="overflow-y-auto h-[calc(100vh-64px)] md:h-[calc(80vh-64px)] p-6">
|
||||
{/* Chart */}
|
||||
<div className="h-[400px] mb-6">
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={sortedPerformances}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="year" />
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string) => {
|
||||
if (name === 'Performance') return [`${value.toFixed(2)}%`, name];
|
||||
return [`€${value.toLocaleString()}`, 'Portfolio Value'];
|
||||
}}
|
||||
labelFormatter={(year) => `Year ${year}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="percentage"
|
||||
name="Performance"
|
||||
stroke="url(#colorGradient)"
|
||||
dot={<CustomizedDot />}
|
||||
strokeWidth={2}
|
||||
yAxisId="left"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22c55e" />
|
||||
<stop offset="100%" stopColor="#ef4444" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Performance Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 dark:text-gray-300">
|
||||
{sortedPerformances.map(({ year, percentage }) => (
|
||||
<div
|
||||
key={year}
|
||||
className={`p-3 rounded-lg ${
|
||||
percentage >= 0 ? 'bg-green-100 dark:bg-green-900/30' : 'bg-red-100 dark:bg-red-900/30'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{year}</div>
|
||||
<div className={`text-lg font-bold ${
|
||||
percentage >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{percentage.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,331 +1,331 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
import React, { memo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useLocaleDateFormat } from "../hooks/useLocalDateFormat";
|
||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||
import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
|
||||
|
||||
export default function InvestmentFormWrapper() {
|
||||
const { assets, clearAssets } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
clearAssets: state.clearAssets,
|
||||
}));
|
||||
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||
|
||||
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 pb-2">
|
||||
<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="flex-1 h-[calc(100%-120px)] overflow-hidden">
|
||||
<div className="p-6 pr-3 pt-0">
|
||||
<InvestmentForm assetId={selectedAsset} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntervalConfig {
|
||||
value: number;
|
||||
unit: 'days' | 'months' | 'years';
|
||||
}
|
||||
|
||||
const InvestmentForm = memo(({ assetId }: { assetId: string }) => {
|
||||
const [type, setType] = useState<'single' | 'periodic'>('single');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [dayOfMonth, setDayOfMonth] = useState('1');
|
||||
const [isDynamic, setIsDynamic] = useState(false);
|
||||
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
|
||||
const [dynamicValue, setDynamicValue] = useState('');
|
||||
const [yearInterval, setYearInterval] = useState('1');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [intervalConfig, setIntervalConfig] = useState<IntervalConfig>({
|
||||
value: 1,
|
||||
unit: 'months'
|
||||
});
|
||||
const [showIntervalWarning, setShowIntervalWarning] = useState(false);
|
||||
|
||||
const localeDateFormat = useLocaleDateFormat();
|
||||
|
||||
const { dateRange, addInvestment } = usePortfolioSelector((state) => ({
|
||||
dateRange: state.dateRange,
|
||||
addInvestment: state.addInvestment,
|
||||
}));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (type === "single") {
|
||||
const investment = {
|
||||
id: crypto.randomUUID(),
|
||||
assetId,
|
||||
type,
|
||||
amount: parseFloat(amount),
|
||||
date: new Date(date),
|
||||
};
|
||||
addInvestment(assetId, investment);
|
||||
toast.success('Investment added successfully');
|
||||
} else {
|
||||
const periodicSettings = {
|
||||
startDate: new Date(date),
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: intervalConfig.value,
|
||||
amount: parseFloat(amount),
|
||||
intervalUnit: intervalConfig.unit,
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
type: dynamicType,
|
||||
value: parseFloat(dynamicValue),
|
||||
yearInterval: parseInt(yearInterval),
|
||||
},
|
||||
} : undefined),
|
||||
};
|
||||
|
||||
const investments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
new Date(dateRange.endDate),
|
||||
assetId
|
||||
);
|
||||
addInvestment(assetId, investments);
|
||||
|
||||
toast.success('Sparplan erfolgreich erstellt');
|
||||
}
|
||||
} catch (error:any) {
|
||||
toast.error('Fehler beim Erstellen des Investments: ' + String(error?.message || error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setAmount('');
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleIntervalUnitChange = (unit: IntervalConfig['unit']) => {
|
||||
setIntervalConfig(prev => ({
|
||||
...prev,
|
||||
unit
|
||||
}));
|
||||
|
||||
setShowIntervalWarning(['days', 'weeks'].includes(unit));
|
||||
};
|
||||
|
||||
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"
|
||||
autoFocus
|
||||
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 {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}</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>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Interval
|
||||
<span className="ml-1 text-gray-400 hover:text-gray-600 cursor-help" title="Wählen Sie das Intervall für Ihre regelmäßigen Investitionen. Bei monatlichen Zahlungen am 1. eines Monats werden die Investments automatisch am 1. jeden Monats ausgeführt.">
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={intervalConfig.value}
|
||||
onChange={(e) => setIntervalConfig(prev => ({
|
||||
...prev,
|
||||
value: parseInt(e.target.value)
|
||||
}))}
|
||||
className="w-24 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={intervalConfig.unit}
|
||||
onChange={(e) => handleIntervalUnitChange(e.target.value as IntervalConfig['unit'])}
|
||||
className="flex-1 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
<option value="quarters">Quarters</option>
|
||||
<option value="years">Years</option>
|
||||
</select>
|
||||
</div>
|
||||
{showIntervalWarning && (
|
||||
<p className="mt-2 text-sm text-amber-500 dark:text-amber-400">
|
||||
Warning: Using short intervals (days/weeks) may result in longer calculation times due to the higher number of investments to process.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">SavingsPlan-Start Datum {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}</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
|
||||
lang="de"
|
||||
/>
|
||||
</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"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="animate-spin mx-auto" size={16} />
|
||||
) : (
|
||||
'Add Investment'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
import { Loader2 } from "lucide-react";
|
||||
import React, { memo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useLocaleDateFormat } from "../hooks/useLocalDateFormat";
|
||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||
import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
|
||||
|
||||
export default function InvestmentFormWrapper() {
|
||||
const { assets, clearAssets } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
clearAssets: state.clearAssets,
|
||||
}));
|
||||
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||
|
||||
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 pb-2">
|
||||
<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="flex-1 h-[calc(100%-120px)] overflow-hidden">
|
||||
<div className="p-6 pr-3 pt-0">
|
||||
<InvestmentForm assetId={selectedAsset} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IntervalConfig {
|
||||
value: number;
|
||||
unit: 'days' | 'months' | 'years';
|
||||
}
|
||||
|
||||
const InvestmentForm = memo(({ assetId }: { assetId: string }) => {
|
||||
const [type, setType] = useState<'single' | 'periodic'>('single');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [dayOfMonth, setDayOfMonth] = useState('1');
|
||||
const [isDynamic, setIsDynamic] = useState(false);
|
||||
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
|
||||
const [dynamicValue, setDynamicValue] = useState('');
|
||||
const [yearInterval, setYearInterval] = useState('1');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [intervalConfig, setIntervalConfig] = useState<IntervalConfig>({
|
||||
value: 1,
|
||||
unit: 'months'
|
||||
});
|
||||
const [showIntervalWarning, setShowIntervalWarning] = useState(false);
|
||||
|
||||
const localeDateFormat = useLocaleDateFormat();
|
||||
|
||||
const { dateRange, addInvestment } = usePortfolioSelector((state) => ({
|
||||
dateRange: state.dateRange,
|
||||
addInvestment: state.addInvestment,
|
||||
}));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (type === "single") {
|
||||
const investment = {
|
||||
id: crypto.randomUUID(),
|
||||
assetId,
|
||||
type,
|
||||
amount: parseFloat(amount),
|
||||
date: new Date(date),
|
||||
};
|
||||
addInvestment(assetId, investment);
|
||||
toast.success('Investment added successfully');
|
||||
} else {
|
||||
const periodicSettings = {
|
||||
startDate: new Date(date),
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: intervalConfig.value,
|
||||
amount: parseFloat(amount),
|
||||
intervalUnit: intervalConfig.unit,
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
type: dynamicType,
|
||||
value: parseFloat(dynamicValue),
|
||||
yearInterval: parseInt(yearInterval),
|
||||
},
|
||||
} : undefined),
|
||||
};
|
||||
|
||||
const investments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
new Date(dateRange.endDate),
|
||||
assetId
|
||||
);
|
||||
addInvestment(assetId, investments);
|
||||
|
||||
toast.success('Sparplan erfolgreich erstellt');
|
||||
}
|
||||
} catch (error:any) {
|
||||
toast.error('Fehler beim Erstellen des Investments: ' + String(error?.message || error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setAmount('');
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleIntervalUnitChange = (unit: IntervalConfig['unit']) => {
|
||||
setIntervalConfig(prev => ({
|
||||
...prev,
|
||||
unit
|
||||
}));
|
||||
|
||||
setShowIntervalWarning(['days', 'weeks'].includes(unit));
|
||||
};
|
||||
|
||||
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"
|
||||
autoFocus
|
||||
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 {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}</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>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Interval
|
||||
<span className="ml-1 text-gray-400 hover:text-gray-600 cursor-help" title="Wählen Sie das Intervall für Ihre regelmäßigen Investitionen. Bei monatlichen Zahlungen am 1. eines Monats werden die Investments automatisch am 1. jeden Monats ausgeführt.">
|
||||
ⓘ
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={intervalConfig.value}
|
||||
onChange={(e) => setIntervalConfig(prev => ({
|
||||
...prev,
|
||||
value: parseInt(e.target.value)
|
||||
}))}
|
||||
className="w-24 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={intervalConfig.unit}
|
||||
onChange={(e) => handleIntervalUnitChange(e.target.value as IntervalConfig['unit'])}
|
||||
className="flex-1 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
<option value="quarters">Quarters</option>
|
||||
<option value="years">Years</option>
|
||||
</select>
|
||||
</div>
|
||||
{showIntervalWarning && (
|
||||
<p className="mt-2 text-sm text-amber-500 dark:text-amber-400">
|
||||
Warning: Using short intervals (days/weeks) may result in longer calculation times due to the higher number of investments to process.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">SavingsPlan-Start Datum {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}</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
|
||||
lang="de"
|
||||
/>
|
||||
</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"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="animate-spin mx-auto" size={16} />
|
||||
) : (
|
||||
'Add Investment'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,55 +1,63 @@
|
|||
import { Heart, Moon, Plus, Sun } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
onAddAsset: () => void;
|
||||
}
|
||||
|
||||
export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||
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 relative">
|
||||
<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={onAddAsset}
|
||||
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>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://github.com/Tomato6966/investment-portfolio-simulator"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fixed bottom-4 left-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Built with <Heart className="w-4 h-4 text-red-500 inline animate-pulse" /> by Tomato6966
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { BarChart2, Heart, Moon, Plus, Sun } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
onAddAsset: () => void;
|
||||
}
|
||||
|
||||
export const AppShell = ({ children, onAddAsset }: AppShellProps) => {
|
||||
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 relative">
|
||||
<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={onAddAsset}
|
||||
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>
|
||||
<Link
|
||||
to="/explore"
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
<BarChart2 className="w-5 h-5" />
|
||||
Stock Explorer
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://github.com/Tomato6966/investment-portfolio-simulator"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="fixed bottom-4 left-4 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Built with <Heart className="w-4 h-4 text-red-500 inline animate-pulse" /> by Tomato6966
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,37 +1,37 @@
|
|||
import { lazy, Suspense } from "react";
|
||||
|
||||
import { LoadingPlaceholder } from "../utils/LoadingPlaceholder";
|
||||
|
||||
const AddAssetModal = lazy(() => import("../Modals/AddAssetModal"));
|
||||
const InvestmentFormWrapper = lazy(() => import("../InvestmentForm"));
|
||||
const PortfolioChart = lazy(() => import("../PortfolioChart"));
|
||||
const PortfolioTable = lazy(() => import("../PortfolioTable"));
|
||||
|
||||
|
||||
export default function MainContent({ isAddingAsset, setIsAddingAsset }: { isAddingAsset: boolean, setIsAddingAsset: (value: boolean) => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 mb-8 dark:text-gray-300">
|
||||
<div className="col-span-3">
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||
<PortfolioChart />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="col-span-3 lg:col-span-1">
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||
<InvestmentFormWrapper />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||
<PortfolioTable />
|
||||
</Suspense>
|
||||
|
||||
{isAddingAsset && (
|
||||
<Suspense>
|
||||
<AddAssetModal onClose={() => setIsAddingAsset(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
import { LoadingPlaceholder } from "../utils/LoadingPlaceholder";
|
||||
|
||||
const AddAssetModal = lazy(() => import("../Modals/AddAssetModal"));
|
||||
const InvestmentFormWrapper = lazy(() => import("../InvestmentForm"));
|
||||
const PortfolioChart = lazy(() => import("../PortfolioChart"));
|
||||
const PortfolioTable = lazy(() => import("../PortfolioTable"));
|
||||
|
||||
|
||||
export default function MainContent({ isAddingAsset, setIsAddingAsset }: { isAddingAsset: boolean, setIsAddingAsset: (value: boolean) => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 mb-8 dark:text-gray-300">
|
||||
<div className="col-span-3">
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||
<PortfolioChart />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="col-span-3 lg:col-span-1">
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||
<InvestmentFormWrapper />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<LoadingPlaceholder className="h-[500px]" />}>
|
||||
<PortfolioTable />
|
||||
</Suspense>
|
||||
|
||||
{isAddingAsset && (
|
||||
<Suspense>
|
||||
<AddAssetModal onClose={() => setIsAddingAsset(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,136 +1,139 @@
|
|||
import { Loader2, Search, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../../services/yahooFinanceService";
|
||||
import { Asset } from "../../types";
|
||||
|
||||
export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||
const [ search, setSearch ] = useState('');
|
||||
const [ searchResults, setSearchResults ] = useState<Asset[]>([]);
|
||||
const [ loading, setLoading ] = useState<null | "searching" | "adding">(null);
|
||||
const [ equityType, setEquityType ] = useState<string>(EQUITY_TYPES.all);
|
||||
const { addAsset, dateRange, assets } = usePortfolioSelector((state) => ({
|
||||
addAsset: state.addAsset,
|
||||
dateRange: state.dateRange,
|
||||
assets: state.assets,
|
||||
}));
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
if (query.length < 2) return;
|
||||
setLoading("searching");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const results = await searchAssets(query, equityType);
|
||||
setSearchResults(results.filter((result) => !assets.some((asset) => asset.symbol === result.symbol)));
|
||||
} catch (error) {
|
||||
console.error('Error searching assets:', error);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const debouncedSearch = useDebouncedCallback(handleSearch, 750);
|
||||
|
||||
const handleAssetSelect = (asset: Asset) => {
|
||||
setLoading("adding");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { historicalData, longName } = await getHistoricalData(
|
||||
asset.symbol,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate
|
||||
);
|
||||
|
||||
if (historicalData.size === 0) {
|
||||
toast.error(`No historical data available for ${asset.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const assetWithHistory = {
|
||||
...asset,
|
||||
name: longName || asset.name,
|
||||
historicalData,
|
||||
};
|
||||
|
||||
addAsset(assetWithHistory);
|
||||
toast.success(`Successfully added ${assetWithHistory.name}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
toast.error(`Failed to add ${asset.name}. Please try again.`);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">Add Asset</h2>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<label className="text-sm font-medium text-gray-800/30 dark:text-gray-200/30">Asset Type:</label>
|
||||
<select value={equityType} onChange={(e) => setEquityType(e.target.value)} className="w-[30%] p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300">
|
||||
{Object.entries(EQUITY_TYPES).map(([key, value]) => (
|
||||
<option key={key} value={value}>{key.charAt(0).toUpperCase() + key.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={onClose} className="p-2">
|
||||
<X className="w-6 h-6 dark:text-gray-200" />
|
||||
</button>
|
||||
</div>
|
||||
</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}
|
||||
autoFocus
|
||||
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="flex items-center text-center py-4 gap-2 dark:text-slate-300">
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
<span>{loading === "searching" ? "Searching Assets..." : "Fetching Details & Adding..."}</span>
|
||||
</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 border-b dark:border-slate-700 border-gray-300"
|
||||
onClick={() => handleAssetSelect(result)}
|
||||
>
|
||||
<div className="font-medium flex justify-between">
|
||||
<span>{result.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={!result.priceChangePercent?.includes("-") ? "text-green-500/75" : "text-red-500/75"}>
|
||||
{!result.priceChangePercent?.includes("-") && "+"}{result.priceChangePercent}
|
||||
</span>
|
||||
{result.price}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Ticker-Symbol: {result.symbol} | Type: {result.quoteType?.toUpperCase() || "Unknown"} | Rank: #{result.rank || "-"}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { Loader2, Search, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../../services/yahooFinanceService";
|
||||
import { Asset } from "../../types";
|
||||
|
||||
export default function AddAssetModal({ onClose }: { onClose: () => void }) {
|
||||
const [ search, setSearch ] = useState('');
|
||||
const [ searchResults, setSearchResults ] = useState<Asset[]>([]);
|
||||
const [ loading, setLoading ] = useState<null | "searching" | "adding">(null);
|
||||
const [ equityType, setEquityType ] = useState<string>(EQUITY_TYPES.all);
|
||||
const { addAsset, dateRange, assets } = usePortfolioSelector((state) => ({
|
||||
addAsset: state.addAsset,
|
||||
dateRange: state.dateRange,
|
||||
assets: state.assets,
|
||||
}));
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
if (query.length < 2) return;
|
||||
setLoading("searching");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const results = await searchAssets(query, equityType);
|
||||
setSearchResults(results.filter((result) => !assets.some((asset) => asset.symbol === result.symbol)));
|
||||
} catch (error) {
|
||||
console.error('Error searching assets:', error);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const debouncedSearch = useDebouncedCallback(handleSearch, 750);
|
||||
|
||||
const handleAssetSelect = (asset: Asset) => {
|
||||
setLoading("adding");
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { historicalData, longName } = await getHistoricalData(
|
||||
asset.symbol,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate
|
||||
);
|
||||
|
||||
if (historicalData.size === 0) {
|
||||
toast.error(`No historical data available for ${asset.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const assetWithHistory = {
|
||||
...asset,
|
||||
name: longName || asset.name,
|
||||
historicalData,
|
||||
};
|
||||
|
||||
addAsset(assetWithHistory);
|
||||
toast.success(`Successfully added ${assetWithHistory.name}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
toast.error(`Failed to add ${asset.name}. Please try again.`);
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">Add Asset</h2>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<label className="text-sm font-medium text-gray-800/30 dark:text-gray-200/30">Asset Type:</label>
|
||||
<select value={equityType} onChange={(e) => {
|
||||
setEquityType(e.target.value);
|
||||
debouncedSearch(search);
|
||||
}} className="w-[30%] p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300">
|
||||
{Object.entries(EQUITY_TYPES).map(([key, value]) => (
|
||||
<option key={key} value={value}>{key.charAt(0).toUpperCase() + key.slice(1)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={onClose} className="p-2">
|
||||
<X className="w-6 h-6 dark:text-gray-200" />
|
||||
</button>
|
||||
</div>
|
||||
</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}
|
||||
autoFocus
|
||||
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="flex items-center text-center py-4 gap-2 dark:text-slate-300">
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
<span>{loading === "searching" ? "Searching Assets..." : "Fetching Details & Adding..."}</span>
|
||||
</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 border-b dark:border-slate-700 border-gray-300"
|
||||
onClick={() => handleAssetSelect(result)}
|
||||
>
|
||||
<div className="font-medium flex justify-between">
|
||||
<span>{result.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={!result.priceChangePercent?.includes("-") ? "text-green-500/75" : "text-red-500/75"}>
|
||||
{!result.priceChangePercent?.includes("-") && "+"}{result.priceChangePercent}
|
||||
</span>
|
||||
{result.price}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Ticker-Symbol: {result.symbol} | Type: {result.quoteType?.toUpperCase() || "Unknown"} | Rank: #{result.rank || "-"}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,87 +1,87 @@
|
|||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { Investment } from "../../types";
|
||||
|
||||
interface EditInvestmentModalProps {
|
||||
investment: Investment;
|
||||
assetId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EditInvestmentModal = ({ investment, assetId, onClose }: EditInvestmentModalProps) => {
|
||||
const { updateInvestment } = usePortfolioSelector((state) => ({
|
||||
updateInvestment: state.updateInvestment,
|
||||
}));
|
||||
const [amount, setAmount] = useState(investment.amount.toString());
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
updateInvestment(assetId, investment.id, {
|
||||
...investment,
|
||||
amount: parseFloat(amount),
|
||||
});
|
||||
toast.success('Investment updated successfully');
|
||||
onClose();
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to update investment' + String(error?.message || error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { Investment } from "../../types";
|
||||
|
||||
interface EditInvestmentModalProps {
|
||||
investment: Investment;
|
||||
assetId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EditInvestmentModal = ({ investment, assetId, onClose }: EditInvestmentModalProps) => {
|
||||
const { updateInvestment } = usePortfolioSelector((state) => ({
|
||||
updateInvestment: state.updateInvestment,
|
||||
}));
|
||||
const [amount, setAmount] = useState(investment.amount.toString());
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
updateInvestment(assetId, investment.id, {
|
||||
...investment,
|
||||
amount: parseFloat(amount),
|
||||
});
|
||||
toast.success('Investment updated successfully');
|
||||
onClose();
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to update investment' + String(error?.message || error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,295 +1,295 @@
|
|||
import { format } from "date-fns";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useLocaleDateFormat } from "../../hooks/useLocalDateFormat";
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { PeriodicSettings } from "../../types";
|
||||
import { generatePeriodicInvestments } from "../../utils/calculations/assetValue";
|
||||
import { Tooltip } from "../utils/ToolTip";
|
||||
|
||||
interface EditSavingsPlanModalProps {
|
||||
assetId: string;
|
||||
groupId: string;
|
||||
amount: number;
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
yearInterval: number;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface IntervalConfig {
|
||||
value: number;
|
||||
unit: 'days' | 'months' | 'years';
|
||||
}
|
||||
|
||||
export const EditSavingsPlanModal = ({
|
||||
assetId,
|
||||
groupId,
|
||||
amount: initialAmount,
|
||||
dayOfMonth: initialDayOfMonth,
|
||||
interval: initialInterval,
|
||||
dynamic: initialDynamic,
|
||||
onClose
|
||||
}: EditSavingsPlanModalProps) => {
|
||||
const [amount, setAmount] = useState(initialAmount.toString());
|
||||
const [dayOfMonth, setDayOfMonth] = useState(initialDayOfMonth.toString());
|
||||
const [interval, setInterval] = useState(initialInterval.toString());
|
||||
const [intervalUnit, setIntervalUnit] = useState<'days' | 'weeks' | 'months' | 'quarters' | 'years'>('months');
|
||||
const [isDynamic, setIsDynamic] = useState(!!initialDynamic);
|
||||
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>(initialDynamic?.type || 'percentage');
|
||||
const [dynamicValue, setDynamicValue] = useState(initialDynamic?.value.toString() || '');
|
||||
const [yearInterval, setYearInterval] = useState(initialDynamic?.yearInterval.toString() || '1');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showIntervalWarning, setShowIntervalWarning] = useState(false);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const localeDateFormat = useLocaleDateFormat();
|
||||
|
||||
const { dateRange, addInvestment, removeInvestment, assets } = usePortfolioSelector((state) => ({
|
||||
dateRange: state.dateRange,
|
||||
addInvestment: state.addInvestment,
|
||||
removeInvestment: state.removeInvestment,
|
||||
assets: state.assets,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const asset = assets.find(a => a.id === assetId)!;
|
||||
const investments = asset.investments.filter(inv => inv.periodicGroupId === groupId);
|
||||
const firstInvestmentDate = investments[0].date!;
|
||||
setStartDate(format(firstInvestmentDate, 'yyyy-MM-dd'));
|
||||
}, [assetId, groupId, assets]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsSubmitting(true);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// First, remove all existing investments for this savings plan
|
||||
const asset = assets.find(a => a.id === assetId)!;
|
||||
const investments = asset.investments.filter(inv => inv.periodicGroupId === groupId);
|
||||
|
||||
investments.forEach(inv => {
|
||||
removeInvestment(assetId, inv.id);
|
||||
});
|
||||
|
||||
// Generate and add new investments with the new start date
|
||||
const periodicSettings: PeriodicSettings = {
|
||||
startDate: new Date(startDate), // Use the new start date
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: parseInt(interval),
|
||||
intervalUnit: intervalUnit,
|
||||
amount: parseFloat(amount),
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
type: dynamicType,
|
||||
value: parseFloat(dynamicValue),
|
||||
yearInterval: parseInt(yearInterval),
|
||||
},
|
||||
} : undefined),
|
||||
};
|
||||
|
||||
const newInvestments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
dateRange.endDate,
|
||||
assetId
|
||||
);
|
||||
|
||||
addInvestment(assetId, newInvestments);
|
||||
toast.success('Savings plan updated successfully');
|
||||
onClose();
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to update savings plan: ' + String(error?.message || error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleIntervalUnitChange = (unit: IntervalConfig['unit']) => {
|
||||
setIntervalUnit(unit);
|
||||
setShowIntervalWarning(['days', 'weeks'].includes(unit));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">Edit Savings Plan</h2>
|
||||
<button onClick={onClose} className="p-2">
|
||||
<X className="w-6 h-6 dark:text-gray-200" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Investment Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Day of Month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dayOfMonth}
|
||||
onChange={(e) => setDayOfMonth(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
max="31"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
<Tooltip content="Choose the interval for your regular investments. For monthly payments on the 1st of a month, investments will automatically be executed on the 1st of each month.">
|
||||
Interval
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="w-24 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={intervalUnit}
|
||||
onChange={(e) => handleIntervalUnitChange(e.target.value as IntervalConfig['unit'])}
|
||||
className="flex-1 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
<option value="quarters">Quarters</option>
|
||||
<option value="years">Years</option>
|
||||
</select>
|
||||
</div>
|
||||
{showIntervalWarning && (
|
||||
<p className="mt-2 text-sm text-amber-500 dark:text-amber-400">
|
||||
Warning: Using short intervals (days/weeks) may result in longer calculation times due to the higher number of investments to process.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 dark:text-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDynamic}
|
||||
onChange={(e) => setIsDynamic(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium">Dynamic Investment Growth</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isDynamic && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Growth Type
|
||||
</label>
|
||||
<select
|
||||
value={dynamicType}
|
||||
onChange={(e) => setDynamicType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="percentage">Percentage</option>
|
||||
<option value="fixed">Fixed Amount</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Increase Value
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dynamicValue}
|
||||
onChange={(e) => setDynamicValue(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="0"
|
||||
step={dynamicType === 'percentage' ? '0.1' : '1'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Year Interval for Increase
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={yearInterval}
|
||||
onChange={(e) => setYearInterval(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Start Date {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(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
|
||||
lang="de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
'Update Plan'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { format } from "date-fns";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useLocaleDateFormat } from "../../hooks/useLocalDateFormat";
|
||||
import { usePortfolioSelector } from "../../hooks/usePortfolio";
|
||||
import { PeriodicSettings } from "../../types";
|
||||
import { generatePeriodicInvestments } from "../../utils/calculations/assetValue";
|
||||
import { Tooltip } from "../utils/ToolTip";
|
||||
|
||||
interface EditSavingsPlanModalProps {
|
||||
assetId: string;
|
||||
groupId: string;
|
||||
amount: number;
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
yearInterval: number;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface IntervalConfig {
|
||||
value: number;
|
||||
unit: 'days' | 'months' | 'years';
|
||||
}
|
||||
|
||||
export const EditSavingsPlanModal = ({
|
||||
assetId,
|
||||
groupId,
|
||||
amount: initialAmount,
|
||||
dayOfMonth: initialDayOfMonth,
|
||||
interval: initialInterval,
|
||||
dynamic: initialDynamic,
|
||||
onClose
|
||||
}: EditSavingsPlanModalProps) => {
|
||||
const [amount, setAmount] = useState(initialAmount.toString());
|
||||
const [dayOfMonth, setDayOfMonth] = useState(initialDayOfMonth.toString());
|
||||
const [interval, setInterval] = useState(initialInterval.toString());
|
||||
const [intervalUnit, setIntervalUnit] = useState<'days' | 'weeks' | 'months' | 'quarters' | 'years'>('months');
|
||||
const [isDynamic, setIsDynamic] = useState(!!initialDynamic);
|
||||
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>(initialDynamic?.type || 'percentage');
|
||||
const [dynamicValue, setDynamicValue] = useState(initialDynamic?.value.toString() || '');
|
||||
const [yearInterval, setYearInterval] = useState(initialDynamic?.yearInterval.toString() || '1');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showIntervalWarning, setShowIntervalWarning] = useState(false);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const localeDateFormat = useLocaleDateFormat();
|
||||
|
||||
const { dateRange, addInvestment, removeInvestment, assets } = usePortfolioSelector((state) => ({
|
||||
dateRange: state.dateRange,
|
||||
addInvestment: state.addInvestment,
|
||||
removeInvestment: state.removeInvestment,
|
||||
assets: state.assets,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
const asset = assets.find(a => a.id === assetId)!;
|
||||
const investments = asset.investments.filter(inv => inv.periodicGroupId === groupId);
|
||||
const firstInvestmentDate = investments[0].date!;
|
||||
setStartDate(format(firstInvestmentDate, 'yyyy-MM-dd'));
|
||||
}, [assetId, groupId, assets]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsSubmitting(true);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// First, remove all existing investments for this savings plan
|
||||
const asset = assets.find(a => a.id === assetId)!;
|
||||
const investments = asset.investments.filter(inv => inv.periodicGroupId === groupId);
|
||||
|
||||
investments.forEach(inv => {
|
||||
removeInvestment(assetId, inv.id);
|
||||
});
|
||||
|
||||
// Generate and add new investments with the new start date
|
||||
const periodicSettings: PeriodicSettings = {
|
||||
startDate: new Date(startDate), // Use the new start date
|
||||
dayOfMonth: parseInt(dayOfMonth),
|
||||
interval: parseInt(interval),
|
||||
intervalUnit: intervalUnit,
|
||||
amount: parseFloat(amount),
|
||||
...(isDynamic ? {
|
||||
dynamic: {
|
||||
type: dynamicType,
|
||||
value: parseFloat(dynamicValue),
|
||||
yearInterval: parseInt(yearInterval),
|
||||
},
|
||||
} : undefined),
|
||||
};
|
||||
|
||||
const newInvestments = generatePeriodicInvestments(
|
||||
periodicSettings,
|
||||
dateRange.endDate,
|
||||
assetId
|
||||
);
|
||||
|
||||
addInvestment(assetId, newInvestments);
|
||||
toast.success('Savings plan updated successfully');
|
||||
onClose();
|
||||
} catch (error:any) {
|
||||
toast.error('Failed to update savings plan: ' + String(error?.message || error));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
const handleIntervalUnitChange = (unit: IntervalConfig['unit']) => {
|
||||
setIntervalUnit(unit);
|
||||
setShowIntervalWarning(['days', 'weeks'].includes(unit));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold dark:text-gray-200">Edit Savings Plan</h2>
|
||||
<button onClick={onClose} className="p-2">
|
||||
<X className="w-6 h-6 dark:text-gray-200" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Investment Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Day of Month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dayOfMonth}
|
||||
onChange={(e) => setDayOfMonth(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
max="31"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
<Tooltip content="Choose the interval for your regular investments. For monthly payments on the 1st of a month, investments will automatically be executed on the 1st of each month.">
|
||||
Interval
|
||||
</Tooltip>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(e.target.value)}
|
||||
className="w-24 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
<select
|
||||
value={intervalUnit}
|
||||
onChange={(e) => handleIntervalUnitChange(e.target.value as IntervalConfig['unit'])}
|
||||
className="flex-1 p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
<option value="quarters">Quarters</option>
|
||||
<option value="years">Years</option>
|
||||
</select>
|
||||
</div>
|
||||
{showIntervalWarning && (
|
||||
<p className="mt-2 text-sm text-amber-500 dark:text-amber-400">
|
||||
Warning: Using short intervals (days/weeks) may result in longer calculation times due to the higher number of investments to process.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 dark:text-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDynamic}
|
||||
onChange={(e) => setIsDynamic(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium">Dynamic Investment Growth</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isDynamic && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Growth Type
|
||||
</label>
|
||||
<select
|
||||
value={dynamicType}
|
||||
onChange={(e) => setDynamicType(e.target.value as 'percentage' | 'fixed')}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
>
|
||||
<option value="percentage">Percentage</option>
|
||||
<option value="fixed">Fixed Amount</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Increase Value
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dynamicValue}
|
||||
onChange={(e) => setDynamicValue(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="0"
|
||||
step={dynamicType === 'percentage' ? '0.1' : '1'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Year Interval for Increase
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={yearInterval}
|
||||
onChange={(e) => setYearInterval(e.target.value)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||
min="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1 dark:text-gray-200">
|
||||
Start Date {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(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
|
||||
lang="de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border rounded hover:bg-gray-100 dark:hover:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
'Update Plan'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,172 +1,189 @@
|
|||
import { format } from "date-fns";
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { useDarkMode } from "../hooks/useDarkMode";
|
||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||
import { getHistoricalData } from "../services/yahooFinanceService";
|
||||
import { DateRange } from "../types";
|
||||
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
||||
import { getHexColor } from "../utils/formatters";
|
||||
import { ChartContent } from "./Chart/ChartContent";
|
||||
|
||||
export default function 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, updateAssetHistoricalData } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
dateRange: state.dateRange,
|
||||
updateDateRange: state.updateDateRange,
|
||||
updateAssetHistoricalData: state.updateAssetHistoricalData,
|
||||
}));
|
||||
|
||||
const fetchHistoricalData = useCallback(
|
||||
async (startDate: Date, endDate: Date) => {
|
||||
for (const asset of assets) {
|
||||
const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate);
|
||||
updateAssetHistoricalData(asset.id, historicalData, longName);
|
||||
}
|
||||
},
|
||||
[assets, updateAssetHistoricalData]
|
||||
);
|
||||
|
||||
const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, {
|
||||
maxWait: 5000,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}, {});
|
||||
}, [assets, isDarkMode]);
|
||||
|
||||
const data = useMemo(() => calculatePortfolioValue(assets, dateRange), [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: { date: string, total: number, invested: number, percentageChange: number, ttwor: number, ttwor_percent: number, [key: string]: number | string } = {
|
||||
date: format(point.date, 'yyyy-MM-dd'),
|
||||
total: point.total,
|
||||
invested: point.invested,
|
||||
percentageChange: point.percentageChange,
|
||||
ttwor: 0,
|
||||
ttwor_percent: 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 handleUpdateDateRange = useCallback((newRange: DateRange) => {
|
||||
updateDateRange(newRange);
|
||||
debouncedFetchHistoricalData(newRange.startDate, newRange.endDate);
|
||||
}, [updateDateRange, debouncedFetchHistoricalData]);
|
||||
|
||||
const [renderKey, setRenderKey] = useState(0);
|
||||
|
||||
const handleReRender = useCallback(() => {
|
||||
setRenderKey(prevKey => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50">
|
||||
<div className="flex justify-between items-center mb-4 p-5">
|
||||
<h2 className="text-xl font-bold dark:text-gray-300">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 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
<ChartContent
|
||||
dateRange={dateRange}
|
||||
handleUpdateDateRange={handleUpdateDateRange}
|
||||
handleReRender={handleReRender}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
renderKey={renderKey}
|
||||
isDarkMode={isDarkMode}
|
||||
hideAssets={hideAssets}
|
||||
hiddenAssets={hiddenAssets}
|
||||
processedData={processedData}
|
||||
assets={assets}
|
||||
assetColors={assetColors}
|
||||
toggleAsset={toggleAsset}
|
||||
toggleAllAssets={toggleAllAssets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white dark:bg-slate-800 p-4 rounded-lg shadow dark:shadow-black/60">
|
||||
<ChartContent
|
||||
dateRange={dateRange}
|
||||
handleUpdateDateRange={handleUpdateDateRange}
|
||||
handleReRender={handleReRender}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
renderKey={renderKey}
|
||||
isDarkMode={isDarkMode}
|
||||
hideAssets={hideAssets}
|
||||
hiddenAssets={hiddenAssets}
|
||||
processedData={processedData}
|
||||
assets={assets}
|
||||
assetColors={assetColors}
|
||||
toggleAsset={toggleAsset}
|
||||
toggleAllAssets={toggleAllAssets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { format } from "date-fns";
|
||||
import { X } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { useDarkMode } from "../hooks/useDarkMode";
|
||||
import { usePortfolioSelector } from "../hooks/usePortfolio";
|
||||
import { getHistoricalData } from "../services/yahooFinanceService";
|
||||
import { DateRange } from "../types";
|
||||
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
||||
import { getHexColor } from "../utils/formatters";
|
||||
import { ChartContent } from "./Chart/ChartContent";
|
||||
|
||||
export default function 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, updateAssetHistoricalData, removeAsset } = usePortfolioSelector((state) => ({
|
||||
assets: state.assets,
|
||||
dateRange: state.dateRange,
|
||||
updateDateRange: state.updateDateRange,
|
||||
updateAssetHistoricalData: state.updateAssetHistoricalData,
|
||||
removeAsset: state.removeAsset,
|
||||
}));
|
||||
|
||||
const fetchHistoricalData = useCallback(
|
||||
async (startDate: Date, endDate: Date) => {
|
||||
for (const asset of assets) {
|
||||
const { historicalData, longName } = await getHistoricalData(asset.symbol, startDate, endDate);
|
||||
updateAssetHistoricalData(asset.id, historicalData, longName);
|
||||
}
|
||||
},
|
||||
[assets, updateAssetHistoricalData]
|
||||
);
|
||||
|
||||
const debouncedFetchHistoricalData = useDebouncedCallback(fetchHistoricalData, 1500, {
|
||||
maxWait: 5000,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}, {});
|
||||
}, [assets, isDarkMode]);
|
||||
|
||||
const data = useMemo(() => calculatePortfolioValue(assets, dateRange), [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]);
|
||||
|
||||
// Compute the initial price for each asset as the first available value (instead of using data[0])
|
||||
const initialPrices = useMemo(() => {
|
||||
const prices: Record<string, number> = {};
|
||||
assets.forEach(asset => {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const price = data[i].assets[asset.id];
|
||||
if (price != null) { // check if data exists
|
||||
prices[asset.id] = price;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return prices;
|
||||
}, [assets, data]);
|
||||
|
||||
// Calculate percentage changes for each asset using the first available price from initialPrices
|
||||
const processedData = useMemo(() => data.map(point => {
|
||||
const processed: { date: string, total: number, invested: number, percentageChange: number, ttwor: number, ttwor_percent: number, [key: string]: number | string } = {
|
||||
date: format(point.date, 'yyyy-MM-dd'),
|
||||
total: point.total,
|
||||
invested: point.invested,
|
||||
percentageChange: point.percentageChange,
|
||||
ttwor: 0,
|
||||
ttwor_percent: 0,
|
||||
};
|
||||
|
||||
for (const asset of assets) {
|
||||
const initialPrice = initialPrices[asset.id]; // use the newly computed initial price
|
||||
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;
|
||||
return processed;
|
||||
}), [data, assets, allAssetsInvestedKapitals, initialPrices]);
|
||||
|
||||
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 handleUpdateDateRange = useCallback((newRange: DateRange) => {
|
||||
updateDateRange(newRange);
|
||||
debouncedFetchHistoricalData(newRange.startDate, newRange.endDate);
|
||||
}, [updateDateRange, debouncedFetchHistoricalData]);
|
||||
|
||||
const [renderKey, setRenderKey] = useState(0);
|
||||
|
||||
const handleReRender = useCallback(() => {
|
||||
setRenderKey(prevKey => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
console.log(processedData);
|
||||
console.log("TEST")
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4 p-5">
|
||||
<h2 className="text-xl font-bold dark:text-gray-300">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 dark:text-gray-300" />
|
||||
</button>
|
||||
</div>
|
||||
<ChartContent
|
||||
dateRange={dateRange}
|
||||
handleUpdateDateRange={handleUpdateDateRange}
|
||||
handleReRender={handleReRender}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
renderKey={renderKey}
|
||||
isDarkMode={isDarkMode}
|
||||
hideAssets={hideAssets}
|
||||
hiddenAssets={hiddenAssets}
|
||||
processedData={processedData}
|
||||
assets={assets}
|
||||
assetColors={assetColors}
|
||||
toggleAsset={toggleAsset}
|
||||
toggleAllAssets={toggleAllAssets}
|
||||
removeAsset={removeAsset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white dark:bg-slate-800 p-4 rounded-lg shadow dark:shadow-black/60">
|
||||
<ChartContent
|
||||
dateRange={dateRange}
|
||||
handleUpdateDateRange={handleUpdateDateRange}
|
||||
handleReRender={handleReRender}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
renderKey={renderKey}
|
||||
isDarkMode={isDarkMode}
|
||||
hideAssets={hideAssets}
|
||||
hiddenAssets={hiddenAssets}
|
||||
processedData={processedData}
|
||||
assets={assets}
|
||||
assetColors={assetColors}
|
||||
toggleAsset={toggleAsset}
|
||||
toggleAllAssets={toggleAllAssets}
|
||||
removeAsset={removeAsset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,93 +1,93 @@
|
|||
import { useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { useLocaleDateFormat } from "../../hooks/useLocalDateFormat";
|
||||
import { formatDateToISO, isValidDate } from "../../utils/formatters";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onStartDateChange: (date: Date) => void;
|
||||
onEndDateChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
export const DateRangePicker = ({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
}: DateRangePickerProps) => {
|
||||
const startDateRef = useRef<HTMLInputElement>(null);
|
||||
const endDateRef = useRef<HTMLInputElement>(null);
|
||||
const localeDateFormat = useLocaleDateFormat();
|
||||
|
||||
const debouncedStartDateChange = useDebouncedCallback(
|
||||
(dateString: string) => {
|
||||
if (isValidDate(dateString)) {
|
||||
const newDate = new Date(dateString);
|
||||
|
||||
if (newDate.getTime() !== startDate.getTime()) {
|
||||
onStartDateChange(newDate);
|
||||
}
|
||||
}
|
||||
},
|
||||
750
|
||||
);
|
||||
|
||||
const debouncedEndDateChange = useDebouncedCallback(
|
||||
(dateString: string) => {
|
||||
if (isValidDate(dateString)) {
|
||||
const newDate = new Date(dateString);
|
||||
|
||||
if (newDate.getTime() !== endDate.getTime()) {
|
||||
onEndDateChange(newDate);
|
||||
}
|
||||
}
|
||||
},
|
||||
750
|
||||
);
|
||||
|
||||
const handleStartDateChange = () => {
|
||||
if (startDateRef.current) {
|
||||
debouncedStartDateChange(startDateRef.current.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = () => {
|
||||
if (endDateRef.current) {
|
||||
debouncedEndDateChange(endDateRef.current.value);
|
||||
}
|
||||
};
|
||||
|
||||
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 {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
||||
</label>
|
||||
<input
|
||||
ref={startDateRef}
|
||||
type="date"
|
||||
defaultValue={formatDateToISO(startDate)}
|
||||
onChange={handleStartDateChange}
|
||||
max={formatDateToISO(endDate)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
To {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
||||
</label>
|
||||
<input
|
||||
ref={endDateRef}
|
||||
type="date"
|
||||
defaultValue={formatDateToISO(endDate)}
|
||||
onChange={handleEndDateChange}
|
||||
min={formatDateToISO(startDate)}
|
||||
max={formatDateToISO(new Date())}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { useRef } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { useLocaleDateFormat } from "../../hooks/useLocalDateFormat";
|
||||
import { formatDateToISO, isValidDate } from "../../utils/formatters";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
onStartDateChange: (date: Date) => void;
|
||||
onEndDateChange: (date: Date) => void;
|
||||
}
|
||||
|
||||
export const DateRangePicker = ({
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
}: DateRangePickerProps) => {
|
||||
const startDateRef = useRef<HTMLInputElement>(null);
|
||||
const endDateRef = useRef<HTMLInputElement>(null);
|
||||
const localeDateFormat = useLocaleDateFormat();
|
||||
|
||||
const debouncedStartDateChange = useDebouncedCallback(
|
||||
(dateString: string) => {
|
||||
if (isValidDate(dateString)) {
|
||||
const newDate = new Date(dateString);
|
||||
|
||||
if (newDate.getTime() !== startDate.getTime()) {
|
||||
onStartDateChange(newDate);
|
||||
}
|
||||
}
|
||||
},
|
||||
750
|
||||
);
|
||||
|
||||
const debouncedEndDateChange = useDebouncedCallback(
|
||||
(dateString: string) => {
|
||||
if (isValidDate(dateString)) {
|
||||
const newDate = new Date(dateString);
|
||||
|
||||
if (newDate.getTime() !== endDate.getTime()) {
|
||||
onEndDateChange(newDate);
|
||||
}
|
||||
}
|
||||
},
|
||||
750
|
||||
);
|
||||
|
||||
const handleStartDateChange = () => {
|
||||
if (startDateRef.current) {
|
||||
debouncedStartDateChange(startDateRef.current.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = () => {
|
||||
if (endDateRef.current) {
|
||||
debouncedEndDateChange(endDateRef.current.value);
|
||||
}
|
||||
};
|
||||
|
||||
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 {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
||||
</label>
|
||||
<input
|
||||
ref={startDateRef}
|
||||
type="date"
|
||||
defaultValue={formatDateToISO(startDate)}
|
||||
onChange={handleStartDateChange}
|
||||
max={formatDateToISO(endDate)}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
To {localeDateFormat && <span className="text-xs text-gray-500">({localeDateFormat})</span>}
|
||||
</label>
|
||||
<input
|
||||
ref={endDateRef}
|
||||
type="date"
|
||||
defaultValue={formatDateToISO(endDate)}
|
||||
onChange={handleEndDateChange}
|
||||
min={formatDateToISO(startDate)}
|
||||
max={formatDateToISO(new Date())}
|
||||
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface LoadingPlaceholderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingPlaceholder = ({ className = "" }: LoadingPlaceholderProps) => (
|
||||
<div className={`flex items-center justify-center bg-white dark:bg-slate-800 rounded-lg shadow-lg dark:shadow-black/60 ${className}`}>
|
||||
<Loader2 className="animate-spin text-cyan-500" size={32} />
|
||||
</div>
|
||||
);
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface LoadingPlaceholderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const LoadingPlaceholder = ({ className = "" }: LoadingPlaceholderProps) => (
|
||||
<div className={`flex items-center justify-center bg-white dark:bg-slate-800 rounded-lg shadow-lg dark:shadow-black/60 ${className}`}>
|
||||
<Loader2 className="animate-spin text-cyan-500" size={32} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
import { HelpCircle } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string | ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
};
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
content: string | ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export 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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
|
||||
import { DarkModeContext } from "../providers/DarkModeProvider";
|
||||
|
||||
export const useDarkMode = () => {
|
||||
const context = useContext(DarkModeContext);
|
||||
if (!context) {
|
||||
throw new Error('useDarkMode must be used within a DarkModeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
import { useContext } from "react";
|
||||
|
||||
import { DarkModeContext } from "../providers/DarkModeProvider";
|
||||
|
||||
export const useDarkMode = () => {
|
||||
const context = useContext(DarkModeContext);
|
||||
if (!context) {
|
||||
throw new Error('useDarkMode must be used within a DarkModeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
import { useMemo } from "react";
|
||||
|
||||
export const useLocaleDateFormat = () => {
|
||||
return useMemo(() => {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const testDate = new Date(2024, 0, 1);
|
||||
const formattedParts = formatter.formatToParts(testDate);
|
||||
|
||||
const order = formattedParts
|
||||
.filter(part => part.type !== 'literal') // Entferne Trennzeichen
|
||||
.map(part => part.type);
|
||||
|
||||
return order.join('/').toUpperCase().replace(/DAY/g, 'DD').replace(/MONTH/g, 'MM').replace(/YEAR/g, 'YYYY');
|
||||
}, []);
|
||||
};
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const useLocaleDateFormat = () => {
|
||||
return useMemo(() => {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const testDate = new Date(2024, 0, 1);
|
||||
const formattedParts = formatter.formatToParts(testDate);
|
||||
|
||||
const order = formattedParts
|
||||
.filter(part => part.type !== 'literal') // Entferne Trennzeichen
|
||||
.map(part => part.type);
|
||||
|
||||
return order.join('/').toUpperCase().replace(/DAY/g, 'DD').replace(/MONTH/g, 'MM').replace(/YEAR/g, 'YYYY');
|
||||
}, []);
|
||||
};
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { useContext, useMemo } from "react";
|
||||
|
||||
import { PortfolioContext, PortfolioContextType } from "../providers/PortfolioProvider";
|
||||
|
||||
// main way of how to access the context
|
||||
const usePortfolio = () => {
|
||||
const context = useContext(PortfolioContext);
|
||||
if (!context) {
|
||||
throw new Error('usePortfolio must be used within a PortfolioProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// performance optimized way of accessing the context
|
||||
export const usePortfolioSelector = <T,>(selector: (state: PortfolioContextType) => T): T => {
|
||||
const context = usePortfolio();
|
||||
return useMemo(() => selector(context), [selector, context]);
|
||||
};
|
||||
import { useContext, useMemo } from "react";
|
||||
|
||||
import { PortfolioContext, PortfolioContextType } from "../providers/PortfolioProvider";
|
||||
|
||||
// main way of how to access the context
|
||||
const usePortfolio = () => {
|
||||
const context = useContext(PortfolioContext);
|
||||
if (!context) {
|
||||
throw new Error('usePortfolio must be used within a PortfolioProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// performance optimized way of accessing the context
|
||||
export const usePortfolioSelector = <T,>(selector: (state: PortfolioContextType) => T): T => {
|
||||
const context = usePortfolio();
|
||||
return useMemo(() => selector(context), [selector, context]);
|
||||
};
|
||||
|
|
250
src/index.css
250
src/index.css
|
@ -1,125 +1,125 @@
|
|||
@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;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling für Investment Form */
|
||||
.scrollbar-styled {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
|
||||
.scrollbar-styled::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scrollbar-styled::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background-color: #94a3b8;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark .scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background-color: #475569;
|
||||
}
|
||||
|
||||
.dark .scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.scrollbar-styled {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #94a3b8 transparent;
|
||||
}
|
||||
|
||||
.dark .scrollbar-styled {
|
||||
scrollbar-color: #475569 transparent;
|
||||
}
|
||||
@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;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling für Investment Form */
|
||||
.scrollbar-styled {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
|
||||
.scrollbar-styled::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.scrollbar-styled::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background-color: #94a3b8;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark .scrollbar-styled::-webkit-scrollbar-thumb {
|
||||
background-color: #475569;
|
||||
}
|
||||
|
||||
.dark .scrollbar-styled::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.scrollbar-styled {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #94a3b8 transparent;
|
||||
}
|
||||
|
||||
.dark .scrollbar-styled {
|
||||
scrollbar-color: #475569 transparent;
|
||||
}
|
||||
|
|
34
src/main.tsx
34
src/main.tsx
|
@ -1,15 +1,19 @@
|
|||
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>
|
||||
);
|
||||
import "./index.css";
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
import App from "./App.tsx";
|
||||
import { DarkModeProvider } from "./providers/DarkModeProvider.tsx";
|
||||
|
||||
// Let App handle the route definitions
|
||||
const router = createBrowserRouter(App);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<DarkModeProvider>
|
||||
<RouterProvider router={router} />
|
||||
</DarkModeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
680
src/pages/StockExplorer.tsx
Normal file
680
src/pages/StockExplorer.tsx
Normal file
|
@ -0,0 +1,680 @@
|
|||
import { format, subYears } from "date-fns";
|
||||
import { ChevronDown, ChevronLeft, Filter, Plus, RefreshCw, Search, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from "recharts";
|
||||
|
||||
import { useDarkMode } from "../hooks/useDarkMode";
|
||||
import { EQUITY_TYPES, getHistoricalData, searchAssets } from "../services/yahooFinanceService";
|
||||
import { Asset } from "../types";
|
||||
import { getHexColor } from "../utils/formatters";
|
||||
|
||||
// Time period options
|
||||
const TIME_PERIODS = {
|
||||
YTD: "Year to Date",
|
||||
"1Y": "1 Year",
|
||||
"3Y": "3 Years",
|
||||
"5Y": "5 Years",
|
||||
"10Y": "10 Years",
|
||||
"15Y": "15 Years",
|
||||
"20Y": "20 Years",
|
||||
MAX: "Maximum",
|
||||
CUSTOM: "Custom Range"
|
||||
};
|
||||
|
||||
// Equity type options
|
||||
const EQUITY_TYPESMAP: Record<keyof typeof EQUITY_TYPES, string> = {
|
||||
all: "All Types",
|
||||
ETF: "ETFs",
|
||||
Stock: "Stocks",
|
||||
"Etf or Stock": "ETF or Stock",
|
||||
Mutualfund: "Mutual Funds",
|
||||
Index: "Indices",
|
||||
Currency: "Currencies",
|
||||
Cryptocurrency: "Cryptocurrencies",
|
||||
Future: "Futures",
|
||||
};
|
||||
|
||||
const StockExplorer = () => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<Asset[]>([]);
|
||||
const [selectedStocks, setSelectedStocks] = useState<Asset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [timePeriod, setTimePeriod] = useState<keyof typeof TIME_PERIODS>("1Y");
|
||||
const [equityType, setEquityType] = useState<keyof typeof EQUITY_TYPESMAP>("all");
|
||||
const [showEquityTypeDropdown, setShowEquityTypeDropdown] = useState(false);
|
||||
const [dateRange, setDateRange] = useState({
|
||||
startDate: subYears(new Date(), 1),
|
||||
endDate: new Date()
|
||||
});
|
||||
const [customDateRange, setCustomDateRange] = useState({
|
||||
startDate: subYears(new Date(), 1),
|
||||
endDate: new Date()
|
||||
});
|
||||
const [stockData, setStockData] = useState<any[]>([]);
|
||||
const [stockColors, setStockColors] = useState<Record<string, string>>({});
|
||||
const { isDarkMode } = useDarkMode();
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!searchQuery || searchQuery.length < 2) {
|
||||
// Clear results if query is too short
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
// Convert the equity type to a comma-separated string for the API
|
||||
const typeParam = EQUITY_TYPES[equityType];
|
||||
|
||||
console.log(`Searching for "${searchQuery}" with type "${typeParam}"`);
|
||||
|
||||
const results = await searchAssets(searchQuery, typeParam);
|
||||
|
||||
console.log("Search results:", results);
|
||||
|
||||
// Filter out stocks already in the selected list
|
||||
const filteredResults = results.filter(
|
||||
result => !selectedStocks.some(stock => stock.symbol === result.symbol)
|
||||
);
|
||||
|
||||
setSearchResults(filteredResults);
|
||||
|
||||
if (filteredResults.length === 0 && results.length > 0) {
|
||||
toast.custom((t: any) => (
|
||||
<div className={`${t.visible ? 'animate-in' : 'animate-out'}`}>
|
||||
All matching results are already in your comparison
|
||||
</div>
|
||||
));
|
||||
} else if (filteredResults.length === 0) {
|
||||
toast.error(`No ${equityType === 'all' ? '' : EQUITY_TYPESMAP[equityType]} results found for "${searchQuery}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
toast.error("Failed to search for stocks");
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}, [searchQuery, equityType, selectedStocks]);
|
||||
|
||||
// Handle enter key press in search input
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// Add stock to comparison
|
||||
const addStock = useCallback(async (stock: Asset) => {
|
||||
// Check if the stock is already selected
|
||||
if (selectedStocks.some(s => s.symbol === stock.symbol)) {
|
||||
toast.error(`${stock.name} is already in your comparison`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const { historicalData, longName } = await getHistoricalData(
|
||||
stock.symbol,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate
|
||||
);
|
||||
|
||||
if (historicalData.size === 0) {
|
||||
toast.error(`No historical data available for ${stock.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const stockWithHistory = {
|
||||
...stock,
|
||||
name: longName || stock.name,
|
||||
historicalData,
|
||||
investments: [] // Empty as we're just exploring
|
||||
};
|
||||
|
||||
// Update selected stocks without causing an extra refresh
|
||||
setSelectedStocks(prev => [...prev, stockWithHistory]);
|
||||
|
||||
// Assign a color
|
||||
setStockColors(prev => {
|
||||
const usedColors = new Set(Object.values(prev));
|
||||
const color = getHexColor(usedColors, isDarkMode);
|
||||
return { ...prev, [stockWithHistory.id]: color };
|
||||
});
|
||||
|
||||
// Don't clear results, so users can add multiple stocks
|
||||
// Just clear the specific stock that was added
|
||||
setSearchResults(prev => prev.filter(result => result.symbol !== stock.symbol));
|
||||
|
||||
toast.success(`Added ${stockWithHistory.name} to comparison`);
|
||||
} catch (error) {
|
||||
console.error("Error adding stock:", error);
|
||||
toast.error(`Failed to add ${stock.name}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange, isDarkMode, selectedStocks]);
|
||||
|
||||
// Remove stock from comparison
|
||||
const removeStock = useCallback((stockId: string) => {
|
||||
setSelectedStocks(prev => prev.filter(stock => stock.id !== stockId));
|
||||
}, []);
|
||||
|
||||
// Update time period and date range
|
||||
const updateTimePeriod = useCallback((period: keyof typeof TIME_PERIODS) => {
|
||||
setTimePeriod(period);
|
||||
|
||||
const endDate = new Date();
|
||||
let startDate;
|
||||
|
||||
switch (period) {
|
||||
case "YTD":
|
||||
startDate = new Date(endDate.getFullYear(), 0, 1); // Jan 1 of current year
|
||||
break;
|
||||
case "1Y":
|
||||
startDate = subYears(endDate, 1);
|
||||
break;
|
||||
case "3Y":
|
||||
startDate = subYears(endDate, 3);
|
||||
break;
|
||||
case "5Y":
|
||||
startDate = subYears(endDate, 5);
|
||||
break;
|
||||
case "10Y":
|
||||
startDate = subYears(endDate, 10);
|
||||
break;
|
||||
case "15Y":
|
||||
startDate = subYears(endDate, 15);
|
||||
break;
|
||||
case "20Y":
|
||||
startDate = subYears(endDate, 20);
|
||||
break;
|
||||
case "MAX":
|
||||
startDate = new Date(1970, 0, 1); // Very early date for "max"
|
||||
break;
|
||||
case "CUSTOM":
|
||||
// Keep the existing custom range
|
||||
startDate = customDateRange.startDate;
|
||||
break;
|
||||
default:
|
||||
startDate = subYears(endDate, 1);
|
||||
}
|
||||
|
||||
if (period !== "CUSTOM") {
|
||||
setDateRange({ startDate, endDate });
|
||||
} else {
|
||||
setDateRange(customDateRange);
|
||||
}
|
||||
}, [customDateRange]);
|
||||
|
||||
// Process the stock data for display
|
||||
const processStockData = useCallback((stocks: Asset[]) => {
|
||||
// Create a combined dataset with data points for all dates
|
||||
const allDates = new Set<string>();
|
||||
const stockValues: Record<string, Record<string, number>> = {};
|
||||
|
||||
// First gather all dates and initial values
|
||||
stocks.forEach(stock => {
|
||||
stockValues[stock.id] = {};
|
||||
|
||||
stock.historicalData.forEach((value, dateStr) => {
|
||||
allDates.add(dateStr);
|
||||
stockValues[stock.id][dateStr] = value;
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array of data points
|
||||
const sortedDates = Array.from(allDates).sort();
|
||||
return sortedDates.map(dateStr => {
|
||||
const dataPoint: Record<string, any> = { date: dateStr };
|
||||
|
||||
// Add base value for each stock
|
||||
stocks.forEach(stock => {
|
||||
if (stockValues[stock.id][dateStr] !== undefined) {
|
||||
dataPoint[stock.id] = stockValues[stock.id][dateStr];
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate percentage change for each stock
|
||||
stocks.forEach(stock => {
|
||||
// Find first available value for this stock
|
||||
const firstValue = Object.values(stockValues[stock.id])[0];
|
||||
const currentValue = stockValues[stock.id][dateStr];
|
||||
|
||||
if (firstValue && currentValue) {
|
||||
dataPoint[`${stock.id}_percent`] =
|
||||
((currentValue - firstValue) / firstValue) * 100;
|
||||
}
|
||||
});
|
||||
|
||||
return dataPoint;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Refresh stock data when stocks or date range changes
|
||||
const refreshStockData = useCallback(async () => {
|
||||
if (selectedStocks.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch updated data for each stock
|
||||
const updatedStocks = await Promise.all(
|
||||
selectedStocks.map(async stock => {
|
||||
const { historicalData, longName } = await getHistoricalData(
|
||||
stock.symbol,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate
|
||||
);
|
||||
|
||||
return {
|
||||
...stock,
|
||||
name: longName || stock.name,
|
||||
historicalData
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Update chart data
|
||||
setStockData(processStockData(updatedStocks));
|
||||
|
||||
// Unconditionally update selectedStocks so the table refreshes
|
||||
setSelectedStocks(updatedStocks);
|
||||
|
||||
toast.success("Stock data refreshed");
|
||||
} catch (error) {
|
||||
console.error("Error refreshing data:", error);
|
||||
toast.error("Failed to refresh stock data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateRange, processStockData]);
|
||||
|
||||
// Calculate performance metrics for each stock with best/worst year
|
||||
const calculatePerformanceMetrics = useCallback((stock: Asset) => {
|
||||
const historicalData = Array.from(stock.historicalData.entries());
|
||||
if (historicalData.length < 2) return {
|
||||
ytd: "N/A",
|
||||
total: "N/A",
|
||||
annualized: "N/A",
|
||||
};
|
||||
|
||||
// Sort by date
|
||||
historicalData.sort((a, b) =>
|
||||
new Date(a[0]).getTime() - new Date(b[0]).getTime()
|
||||
);
|
||||
|
||||
const firstValue = historicalData[0][1];
|
||||
const lastValue = historicalData[historicalData.length - 1][1];
|
||||
|
||||
// Calculate total return
|
||||
const totalPercentChange = ((lastValue - firstValue) / firstValue) * 100;
|
||||
|
||||
// Calculate annualized return using a more precise year duration (365.25 days) and standard CAGR
|
||||
const firstDate = new Date(historicalData[0][0]);
|
||||
const lastDate = new Date(historicalData[historicalData.length - 1][0]);
|
||||
const yearsDiff = (lastDate.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
|
||||
const annualizedReturn = (Math.pow(lastValue / firstValue, 1 / yearsDiff) - 1) * 100;
|
||||
|
||||
return {
|
||||
total: `${totalPercentChange.toFixed(2)}%`,
|
||||
annualized: `${annualizedReturn.toFixed(2)}%/year`,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Effect to refresh data when time period or stocks change
|
||||
useEffect(() => {
|
||||
// Only refresh when stocks are added/removed or dateRange changes
|
||||
refreshStockData();
|
||||
// Don't include refreshStockData in dependencies
|
||||
}, [selectedStocks.length, dateRange]);
|
||||
|
||||
// Update custom date range
|
||||
const handleCustomDateChange = useCallback((start: Date, end: Date) => {
|
||||
const newRange = { startDate: start, endDate: end };
|
||||
setCustomDateRange(newRange);
|
||||
if (timePeriod === "CUSTOM") {
|
||||
setDateRange(newRange);
|
||||
}
|
||||
}, [timePeriod]);
|
||||
|
||||
// Add debugging for chart display
|
||||
useEffect(() => {
|
||||
if (selectedStocks.length > 0) {
|
||||
console.log("Selected stocks:", selectedStocks);
|
||||
console.log("Stock data for chart:", stockData);
|
||||
}
|
||||
}, [selectedStocks, stockData]);
|
||||
|
||||
// Ensure processStockData is called immediately when selectedStocks changes
|
||||
useEffect(() => {
|
||||
if (selectedStocks.length > 0) {
|
||||
const processedData = processStockData(selectedStocks);
|
||||
setStockData(processedData);
|
||||
}
|
||||
}, [selectedStocks, processStockData]);
|
||||
|
||||
return (
|
||||
<div className="dark:bg-slate-900 min-h-screen w-full">
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex items-center mb-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-1 text-blue-500 hover:text-blue-700 mr-4"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
<span>Back to Home</span>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold dark:text-white">Stock Explorer</h1>
|
||||
</div>
|
||||
|
||||
{/* Search and add stocks */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4 mb-6 dark:border dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold mb-4 dark:text-gray-200">Add Assets to Compare</h2>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-2 mb-4">
|
||||
<div className="flex-grow relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
disabled={searchLoading}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search for stocks, ETFs, indices..."
|
||||
className="w-full p-2 border rounded disabled:opacity-50 disabled:cursor-not-allowed dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
||||
/>
|
||||
{searchLoading && (
|
||||
<div className="absolute right-3 top-2.5">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-blue-500 rounded-full border-t-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Equity Type Dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowEquityTypeDropdown(!showEquityTypeDropdown)}
|
||||
className="flex items-center gap-2 border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600 min-w-[140px]"
|
||||
>
|
||||
<Filter size={16} />
|
||||
{EQUITY_TYPESMAP[equityType]}
|
||||
<ChevronDown size={16} className="ml-auto" />
|
||||
</button>
|
||||
|
||||
{showEquityTypeDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white dark:bg-slate-700 border dark:border-slate-600 rounded shadow-lg z-10 w-full">
|
||||
{Object.entries(EQUITY_TYPESMAP).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setEquityType(key as keyof typeof EQUITY_TYPESMAP);
|
||||
setShowEquityTypeDropdown(false);
|
||||
}}
|
||||
className={`block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-slate-600 dark:text-white ${equityType === key ? 'bg-blue-50 dark:bg-blue-900/30' : ''
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={searchLoading}
|
||||
>
|
||||
<Search size={16} />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="border rounded mb-4 max-h-[500px] overflow-y-auto dark:border-slate-600">
|
||||
<div className="sticky top-0 bg-gray-100 dark:bg-slate-700 p-2 border-b dark:border-slate-600">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-300">
|
||||
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''} found for "{searchQuery}"
|
||||
</span>
|
||||
</div>
|
||||
{searchResults.map(result => (
|
||||
<div
|
||||
key={result.id}
|
||||
className="p-3 border-b flex justify-between items-center hover:bg-gray-50 dark:hover:bg-slate-700 dark:border-slate-600 dark:text-gray-200"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{result.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{result.symbol} | {result.quoteType?.toUpperCase() || "Unknown"}
|
||||
{result.isin && ` | ${result.isin}`}
|
||||
{result.price && ` | ${result.price}`}
|
||||
{result.priceChangePercent && ` | ${result.priceChangePercent}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => addStock(result)}
|
||||
className="bg-green-500 text-white p-1 rounded hover:bg-green-600"
|
||||
title="Add to comparison"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected stocks */}
|
||||
<div>
|
||||
<h3 className="font-medium mb-2 dark:text-gray-300">Selected Stocks</h3>
|
||||
{selectedStocks.length === 0 ? (
|
||||
<p className="text-gray-500 dark:text-gray-400 italic">No stocks selected for comparison</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedStocks.map(stock => {
|
||||
const metrics = calculatePerformanceMetrics(stock);
|
||||
return (
|
||||
<div
|
||||
key={stock.id}
|
||||
className="bg-gray-100 dark:bg-slate-700 rounded p-2 flex items-center gap-2"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: stockColors[stock.id] }}
|
||||
></div>
|
||||
<span className="dark:text-white">{stock.name}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
({metrics.total})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeStock(stock.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
title="Remove"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time period selector */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4 mb-6 dark:border dark:border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold dark:text-gray-200">Time Period</h2>
|
||||
<button
|
||||
onClick={refreshStockData}
|
||||
className="flex items-center gap-1 text-blue-500 hover:text-blue-700"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin h-4 w-4 border-2 border-blue-500 rounded-full border-t-transparent"></div>
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
Refresh{loading && "ing"} Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{Object.entries(TIME_PERIODS).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => updateTimePeriod(key as keyof typeof TIME_PERIODS)}
|
||||
className={`px-3 py-1 rounded ${timePeriod === key
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom date range selector (only visible when CUSTOM is selected) */}
|
||||
{timePeriod === "CUSTOM" && (
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={format(customDateRange.startDate, 'yyyy-MM-dd')}
|
||||
onChange={(e) => handleCustomDateChange(
|
||||
new Date(e.target.value),
|
||||
customDateRange.endDate
|
||||
)}
|
||||
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={format(customDateRange.endDate, 'yyyy-MM-dd')}
|
||||
onChange={(e) => handleCustomDateChange(
|
||||
customDateRange.startDate,
|
||||
new Date(e.target.value)
|
||||
)}
|
||||
max={format(new Date(), 'yyyy-MM-dd')}
|
||||
className="border p-2 rounded dark:bg-slate-700 dark:text-white dark:border-slate-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing data from {format(dateRange.startDate, 'MMM d, yyyy')} to {format(dateRange.endDate, 'MMM d, yyyy')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
{selectedStocks.length > 0 && stockData.length > 0 && (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow p-4 mb-6 dark:border dark:border-slate-700">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold dark:text-gray-200">Performance Comparison</h2>
|
||||
</div>
|
||||
|
||||
<div className="h-[500px] mb-6">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={stockData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: isDarkMode ? '#1e293b' : '#fff',
|
||||
border: 'none',
|
||||
color: isDarkMode ? '#d1d5d1' : '#000000',
|
||||
boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
formatter={(value: number, name: string, props: any) => {
|
||||
const stockId = name.replace('_percent', '');
|
||||
const price = props.payload[stockId] || 0;
|
||||
const stockName = selectedStocks.find(s => s.id === stockId)?.name || name;
|
||||
return [
|
||||
`${value.toFixed(2)}% (€${price.toFixed(2)})`,
|
||||
stockName
|
||||
];
|
||||
}}
|
||||
labelFormatter={(date) => format(new Date(date), 'dd.MM.yyyy')}
|
||||
/>
|
||||
<Legend />
|
||||
|
||||
{/* Only percentage lines */}
|
||||
{selectedStocks.map(stock => (
|
||||
<Line
|
||||
key={`${stock.id}_percent`}
|
||||
type="monotone"
|
||||
dataKey={`${stock.id}_percent`}
|
||||
name={stock.name}
|
||||
stroke={stockColors[stock.id]}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Performance metrics table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 dark:bg-slate-700">
|
||||
<th className="p-2 text-left dark:text-gray-200">Stock</th>
|
||||
<th className="p-2 text-right dark:text-gray-200">Total Return</th>
|
||||
<th className="p-2 text-right dark:text-gray-200">Annualized Return</th>
|
||||
<th className="p-2 text-right dark:text-gray-200">Current Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedStocks.map(stock => {
|
||||
const metrics = calculatePerformanceMetrics(stock);
|
||||
const historicalData = Array.from(stock.historicalData.entries());
|
||||
const currentPrice = historicalData.length > 0
|
||||
? historicalData[historicalData.length - 1][1]
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<tr key={stock.id} className="border-b dark:border-slate-600">
|
||||
<td className="p-2 dark:text-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: stockColors[stock.id] }}
|
||||
></div>
|
||||
{stock.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-right dark:text-gray-200">{metrics.total}</td>
|
||||
<td className="p-2 text-right dark:text-gray-200">{metrics.annualized}</td>
|
||||
<td className="p-2 text-right dark:text-gray-200">€{currentPrice.toFixed(2)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockExplorer;
|
|
@ -1,38 +1,38 @@
|
|||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
interface DarkModeContextType {
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
export const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
interface DarkModeContextType {
|
||||
isDarkMode: boolean;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
export const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,172 +1,172 @@
|
|||
import { startOfYear } from "date-fns";
|
||||
import { createContext, useMemo, useReducer } from "react";
|
||||
|
||||
import { Asset, DateRange, Investment } from "../types";
|
||||
|
||||
// State Types
|
||||
interface PortfolioState {
|
||||
assets: Asset[];
|
||||
isLoading: boolean;
|
||||
dateRange: DateRange;
|
||||
}
|
||||
|
||||
// Action Types
|
||||
type PortfolioAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'ADD_ASSET'; payload: Asset }
|
||||
| { type: 'REMOVE_ASSET'; payload: string }
|
||||
| { type: 'CLEAR_ASSETS' }
|
||||
| { type: 'ADD_INVESTMENT'; payload: { assetId: string; investment: Investment | Investment[] } }
|
||||
| { type: 'REMOVE_INVESTMENT'; payload: { assetId: string; investmentId: string } }
|
||||
| { type: 'UPDATE_DATE_RANGE'; payload: DateRange }
|
||||
| { type: 'UPDATE_ASSET_HISTORICAL_DATA'; payload: { assetId: string; historicalData: Asset['historicalData']; longName?: string } }
|
||||
| { type: 'UPDATE_INVESTMENT'; payload: { assetId: string; investmentId: string; investment: Investment } }
|
||||
| { type: 'CLEAR_INVESTMENTS' }
|
||||
| { type: 'SET_ASSETS'; payload: Asset[] };
|
||||
|
||||
// Initial State
|
||||
const initialState: PortfolioState = {
|
||||
assets: [],
|
||||
isLoading: false,
|
||||
dateRange: {
|
||||
startDate: startOfYear(new Date()),
|
||||
endDate: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
// Reducer
|
||||
const portfolioReducer = (state: PortfolioState, action: PortfolioAction): PortfolioState => {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return { ...state, isLoading: action.payload };
|
||||
|
||||
case 'ADD_ASSET':
|
||||
return { ...state, assets: [...state.assets, action.payload] };
|
||||
|
||||
case 'REMOVE_ASSET':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.filter(asset => asset.id !== action.payload)
|
||||
};
|
||||
|
||||
case 'CLEAR_ASSETS':
|
||||
return { ...state, assets: [] };
|
||||
|
||||
case 'ADD_INVESTMENT':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? { ...asset, investments: [...asset.investments, ...(Array.isArray(action.payload.investment) ? action.payload.investment : [action.payload.investment])] }
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
||||
case 'REMOVE_INVESTMENT':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? {
|
||||
...asset,
|
||||
investments: asset.investments.filter(inv => inv.id !== action.payload.investmentId)
|
||||
}
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
||||
case 'UPDATE_DATE_RANGE':
|
||||
return { ...state, dateRange: action.payload };
|
||||
|
||||
case 'UPDATE_ASSET_HISTORICAL_DATA':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? {
|
||||
...asset,
|
||||
historicalData: action.payload.historicalData,
|
||||
name: action.payload.longName || asset.name
|
||||
}
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
||||
case 'UPDATE_INVESTMENT':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? {
|
||||
...asset,
|
||||
investments: asset.investments.map(inv =>
|
||||
inv.id === action.payload.investmentId ? action.payload.investment : inv
|
||||
)
|
||||
}
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
||||
case 'CLEAR_INVESTMENTS':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset => ({ ...asset, investments: [] }))
|
||||
};
|
||||
|
||||
case 'SET_ASSETS':
|
||||
return { ...state, assets: action.payload };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Context
|
||||
export interface PortfolioContextType extends PortfolioState {
|
||||
setLoading: (loading: boolean) => void;
|
||||
addAsset: (asset: Asset) => void;
|
||||
removeAsset: (assetId: string) => void;
|
||||
clearAssets: () => void;
|
||||
addInvestment: (assetId: string, investment: Investment | Investment[]) => void;
|
||||
removeInvestment: (assetId: string, investmentId: string) => void;
|
||||
updateDateRange: (dateRange: DateRange) => void;
|
||||
updateAssetHistoricalData: (assetId: string, historicalData: Asset['historicalData'], longName?: string) => void;
|
||||
updateInvestment: (assetId: string, investmentId: string, investment: Investment) => void;
|
||||
clearInvestments: () => void;
|
||||
setAssets: (assets: Asset[]) => void;
|
||||
}
|
||||
|
||||
export const PortfolioContext = createContext<PortfolioContextType | null>(null);
|
||||
|
||||
// Provider Component
|
||||
export const PortfolioProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(portfolioReducer, initialState);
|
||||
|
||||
// Memoized actions
|
||||
const actions = useMemo(() => ({
|
||||
setLoading: (loading: boolean) => dispatch({ type: 'SET_LOADING', payload: loading }),
|
||||
addAsset: (asset: Asset) => dispatch({ type: 'ADD_ASSET', payload: asset }),
|
||||
removeAsset: (assetId: string) => dispatch({ type: 'REMOVE_ASSET', payload: assetId }),
|
||||
clearAssets: () => dispatch({ type: 'CLEAR_ASSETS' }),
|
||||
addInvestment: (assetId: string, investment: Investment | Investment[]) =>
|
||||
dispatch({ type: 'ADD_INVESTMENT', payload: { assetId, investment } }),
|
||||
removeInvestment: (assetId: string, investmentId: string) =>
|
||||
dispatch({ type: 'REMOVE_INVESTMENT', payload: { assetId, investmentId } }),
|
||||
updateDateRange: (dateRange: DateRange) =>
|
||||
dispatch({ type: 'UPDATE_DATE_RANGE', payload: dateRange }),
|
||||
updateAssetHistoricalData: (assetId: string, historicalData: Asset['historicalData'], longName?: string) =>
|
||||
dispatch({ type: 'UPDATE_ASSET_HISTORICAL_DATA', payload: { assetId, historicalData, longName } }),
|
||||
updateInvestment: (assetId: string, investmentId: string, investment: Investment) =>
|
||||
dispatch({ type: 'UPDATE_INVESTMENT', payload: { assetId, investmentId, investment } }),
|
||||
clearInvestments: () => dispatch({ type: 'CLEAR_INVESTMENTS' }),
|
||||
setAssets: (assets: Asset[]) => dispatch({ type: 'SET_ASSETS', payload: assets }),
|
||||
}), []);
|
||||
|
||||
const value = useMemo(() => ({ ...state, ...actions }), [state, actions]);
|
||||
|
||||
return (
|
||||
<PortfolioContext.Provider value={value}>
|
||||
{children}
|
||||
</PortfolioContext.Provider>
|
||||
);
|
||||
};
|
||||
import { startOfYear } from "date-fns";
|
||||
import { createContext, useMemo, useReducer } from "react";
|
||||
|
||||
import { Asset, DateRange, Investment } from "../types";
|
||||
|
||||
// State Types
|
||||
interface PortfolioState {
|
||||
assets: Asset[];
|
||||
isLoading: boolean;
|
||||
dateRange: DateRange;
|
||||
}
|
||||
|
||||
// Action Types
|
||||
type PortfolioAction =
|
||||
| { type: 'SET_LOADING'; payload: boolean }
|
||||
| { type: 'ADD_ASSET'; payload: Asset }
|
||||
| { type: 'REMOVE_ASSET'; payload: string }
|
||||
| { type: 'CLEAR_ASSETS' }
|
||||
| { type: 'ADD_INVESTMENT'; payload: { assetId: string; investment: Investment | Investment[] } }
|
||||
| { type: 'REMOVE_INVESTMENT'; payload: { assetId: string; investmentId: string } }
|
||||
| { type: 'UPDATE_DATE_RANGE'; payload: DateRange }
|
||||
| { type: 'UPDATE_ASSET_HISTORICAL_DATA'; payload: { assetId: string; historicalData: Asset['historicalData']; longName?: string } }
|
||||
| { type: 'UPDATE_INVESTMENT'; payload: { assetId: string; investmentId: string; investment: Investment } }
|
||||
| { type: 'CLEAR_INVESTMENTS' }
|
||||
| { type: 'SET_ASSETS'; payload: Asset[] };
|
||||
|
||||
// Initial State
|
||||
const initialState: PortfolioState = {
|
||||
assets: [],
|
||||
isLoading: false,
|
||||
dateRange: {
|
||||
startDate: startOfYear(new Date()),
|
||||
endDate: new Date(),
|
||||
},
|
||||
};
|
||||
|
||||
// Reducer
|
||||
const portfolioReducer = (state: PortfolioState, action: PortfolioAction): PortfolioState => {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return { ...state, isLoading: action.payload };
|
||||
|
||||
case 'ADD_ASSET':
|
||||
return { ...state, assets: [...state.assets, action.payload] };
|
||||
|
||||
case 'REMOVE_ASSET':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.filter(asset => asset.id !== action.payload)
|
||||
};
|
||||
|
||||
case 'CLEAR_ASSETS':
|
||||
return { ...state, assets: [] };
|
||||
|
||||
case 'ADD_INVESTMENT':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? { ...asset, investments: [...asset.investments, ...(Array.isArray(action.payload.investment) ? action.payload.investment : [action.payload.investment])] }
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
||||
case 'REMOVE_INVESTMENT':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? {
|
||||
...asset,
|
||||
investments: asset.investments.filter(inv => inv.id !== action.payload.investmentId)
|
||||
}
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
||||
case 'UPDATE_DATE_RANGE':
|
||||
return { ...state, dateRange: action.payload };
|
||||
|
||||
case 'UPDATE_ASSET_HISTORICAL_DATA':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? {
|
||||
...asset,
|
||||
historicalData: action.payload.historicalData,
|
||||
name: action.payload.longName || asset.name
|
||||
}
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
||||
case 'UPDATE_INVESTMENT':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset =>
|
||||
asset.id === action.payload.assetId
|
||||
? {
|
||||
...asset,
|
||||
investments: asset.investments.map(inv =>
|
||||
inv.id === action.payload.investmentId ? action.payload.investment : inv
|
||||
)
|
||||
}
|
||||
: asset
|
||||
)
|
||||
};
|
||||
|
||||
case 'CLEAR_INVESTMENTS':
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map(asset => ({ ...asset, investments: [] }))
|
||||
};
|
||||
|
||||
case 'SET_ASSETS':
|
||||
return { ...state, assets: action.payload };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// Context
|
||||
export interface PortfolioContextType extends PortfolioState {
|
||||
setLoading: (loading: boolean) => void;
|
||||
addAsset: (asset: Asset) => void;
|
||||
removeAsset: (assetId: string) => void;
|
||||
clearAssets: () => void;
|
||||
addInvestment: (assetId: string, investment: Investment | Investment[]) => void;
|
||||
removeInvestment: (assetId: string, investmentId: string) => void;
|
||||
updateDateRange: (dateRange: DateRange) => void;
|
||||
updateAssetHistoricalData: (assetId: string, historicalData: Asset['historicalData'], longName?: string) => void;
|
||||
updateInvestment: (assetId: string, investmentId: string, investment: Investment) => void;
|
||||
clearInvestments: () => void;
|
||||
setAssets: (assets: Asset[]) => void;
|
||||
}
|
||||
|
||||
export const PortfolioContext = createContext<PortfolioContextType | null>(null);
|
||||
|
||||
// Provider Component
|
||||
export const PortfolioProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [state, dispatch] = useReducer(portfolioReducer, initialState);
|
||||
|
||||
// Memoized actions
|
||||
const actions = useMemo(() => ({
|
||||
setLoading: (loading: boolean) => dispatch({ type: 'SET_LOADING', payload: loading }),
|
||||
addAsset: (asset: Asset) => dispatch({ type: 'ADD_ASSET', payload: asset }),
|
||||
removeAsset: (assetId: string) => dispatch({ type: 'REMOVE_ASSET', payload: assetId }),
|
||||
clearAssets: () => dispatch({ type: 'CLEAR_ASSETS' }),
|
||||
addInvestment: (assetId: string, investment: Investment | Investment[]) =>
|
||||
dispatch({ type: 'ADD_INVESTMENT', payload: { assetId, investment } }),
|
||||
removeInvestment: (assetId: string, investmentId: string) =>
|
||||
dispatch({ type: 'REMOVE_INVESTMENT', payload: { assetId, investmentId } }),
|
||||
updateDateRange: (dateRange: DateRange) =>
|
||||
dispatch({ type: 'UPDATE_DATE_RANGE', payload: dateRange }),
|
||||
updateAssetHistoricalData: (assetId: string, historicalData: Asset['historicalData'], longName?: string) =>
|
||||
dispatch({ type: 'UPDATE_ASSET_HISTORICAL_DATA', payload: { assetId, historicalData, longName } }),
|
||||
updateInvestment: (assetId: string, investmentId: string, investment: Investment) =>
|
||||
dispatch({ type: 'UPDATE_INVESTMENT', payload: { assetId, investmentId, investment } }),
|
||||
clearInvestments: () => dispatch({ type: 'CLEAR_INVESTMENTS' }),
|
||||
setAssets: (assets: Asset[]) => dispatch({ type: 'SET_ASSETS', payload: assets }),
|
||||
}), []);
|
||||
|
||||
const value = useMemo(() => ({ ...state, ...actions }), [state, actions]);
|
||||
|
||||
return (
|
||||
<PortfolioContext.Provider value={value}>
|
||||
{children}
|
||||
</PortfolioContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,100 +1,119 @@
|
|||
import type { Asset, YahooSearchResponse, YahooChartResult } from "../types";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { formatDateToISO } from "../utils/formatters";
|
||||
|
||||
// this is only needed when hosted staticly without a proxy server or smt
|
||||
// TODO change it to use the proxy server
|
||||
const isDev = import.meta.env.DEV;
|
||||
const CORS_PROXY = 'https://corsproxy.io/?url=';
|
||||
const YAHOO_API = 'https://query1.finance.yahoo.com';
|
||||
const API_BASE = isDev ? '/yahoo' : `${CORS_PROXY}${encodeURIComponent(YAHOO_API)}`;
|
||||
|
||||
export const EQUITY_TYPES = {
|
||||
all: "etf,equity,mutualfund,index,currency,cryptocurrency,future",
|
||||
ETF: "etf",
|
||||
Stock: "equity",
|
||||
"Etf or Stock": "etf,equity",
|
||||
Mutualfund: "mutualfund",
|
||||
Index: "index",
|
||||
Currency: "currency",
|
||||
Cryptocurrency: "cryptocurrency",
|
||||
Future: "future",
|
||||
};
|
||||
|
||||
export const searchAssets = async (query: string, equityType: string): Promise<Asset[]> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
lang: 'en-US',
|
||||
type: equityType,
|
||||
longName: 'true',
|
||||
});
|
||||
|
||||
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||
const response = await fetch(url);
|
||||
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 => equityType.split(",").map(v => v.toLowerCase()).includes(quote.quoteType.toLowerCase()))
|
||||
.map((quote) => ({
|
||||
id: quote.symbol,
|
||||
isin: '', // not provided by Yahoo Finance API
|
||||
wkn: '', // not provided by Yahoo Finance API
|
||||
name: quote.shortName,
|
||||
rank: quote.rank,
|
||||
symbol: quote.symbol,
|
||||
quoteType: quote.quoteType,
|
||||
price: quote.regularMarketPrice.fmt,
|
||||
priceChange: quote.regularMarketChange.fmt,
|
||||
priceChangePercent: quote.regularMarketPercentChange.fmt,
|
||||
exchange: quote.exchange,
|
||||
historicalData: new Map(),
|
||||
investments: [],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error searching assets:', error);
|
||||
toast.error('Failed to search assets. Please try again later.');
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getHistoricalData = async (symbol: string, startDate: Date, endDate: Date) => {
|
||||
try {
|
||||
const start = Math.floor(startDate.getTime() / 1000);
|
||||
const end = Math.floor(endDate.getTime() / 1000);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
period1: start.toString(),
|
||||
period2: end.toString(),
|
||||
interval: '1d',
|
||||
});
|
||||
|
||||
const url = `${API_BASE}/v8/finance/chart/${symbol}${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
|
||||
const data = await response.json();
|
||||
const { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult;
|
||||
const quotes = indicators.quote[0];
|
||||
|
||||
return {
|
||||
historicalData: new Map(timestamp.map((time: number, index: number) => [formatDateToISO(new Date(time * 1000)), quotes.close[index]])),
|
||||
longName: meta.longName
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
toast.error(`Failed to fetch historical data for ${symbol}. Please try again later.`);
|
||||
return { historicalData: new Map<string, number>(), longName: '' };
|
||||
}
|
||||
};
|
||||
import type { Asset, YahooSearchResponse, YahooChartResult } from "../types";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { formatDateToISO } from "../utils/formatters";
|
||||
|
||||
// this is only needed when hosted staticly without a proxy server or smt
|
||||
// TODO change it to use the proxy server
|
||||
const isDev = import.meta.env.DEV;
|
||||
const CORS_PROXY = 'https://corsproxy.io/?url=';
|
||||
const YAHOO_API = 'https://query1.finance.yahoo.com';
|
||||
const API_BASE = isDev ? '/yahoo' : `${CORS_PROXY}${encodeURIComponent(YAHOO_API)}`;
|
||||
|
||||
export const EQUITY_TYPES = {
|
||||
all: "etf,equity,mutualfund,index,currency,cryptocurrency,future",
|
||||
ETF: "etf",
|
||||
Stock: "equity",
|
||||
"Etf or Stock": "etf,equity",
|
||||
Mutualfund: "mutualfund",
|
||||
Index: "index",
|
||||
Currency: "currency",
|
||||
Cryptocurrency: "cryptocurrency",
|
||||
Future: "future",
|
||||
};
|
||||
|
||||
export const searchAssets = async (query: string, equityType: string): Promise<Asset[]> => {
|
||||
try {
|
||||
// Log input parameters for debugging
|
||||
console.log(`Searching for "${query}" with type "${equityType}"`);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
lang: 'en-US',
|
||||
type: equityType,
|
||||
longName: 'true',
|
||||
});
|
||||
|
||||
const url = `${API_BASE}/v1/finance/lookup${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||
console.log(`Request URL: ${url}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error(`Network error: ${response.status} ${response.statusText}`);
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json() as YahooSearchResponse;
|
||||
console.log("API response:", data);
|
||||
|
||||
if (data.finance.error) {
|
||||
console.error(`API error: ${data.finance.error}`);
|
||||
throw new Error(data.finance.error);
|
||||
}
|
||||
|
||||
if (!data.finance.result?.[0]?.documents) {
|
||||
console.log("No results found");
|
||||
return [];
|
||||
}
|
||||
|
||||
const equityTypes = equityType.split(",").map(v => v.toLowerCase());
|
||||
|
||||
return data.finance.result[0].documents
|
||||
.filter(quote => {
|
||||
const matches = equityTypes.includes(quote.quoteType.toLowerCase());
|
||||
if (!matches) {
|
||||
console.log(`Filtering out ${quote.symbol} (${quote.quoteType}) as it doesn't match ${equityTypes.join(', ')}`);
|
||||
}
|
||||
return matches;
|
||||
})
|
||||
.map((quote) => ({
|
||||
id: quote.symbol,
|
||||
isin: '', // not provided by Yahoo Finance API
|
||||
wkn: '', // not provided by Yahoo Finance API
|
||||
name: quote.shortName,
|
||||
rank: quote.rank,
|
||||
symbol: quote.symbol,
|
||||
quoteType: quote.quoteType,
|
||||
price: quote.regularMarketPrice.fmt,
|
||||
priceChange: quote.regularMarketChange.fmt,
|
||||
priceChangePercent: quote.regularMarketPercentChange.fmt,
|
||||
exchange: quote.exchange,
|
||||
historicalData: new Map(),
|
||||
investments: [],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error searching assets:', error);
|
||||
toast.error('Failed to search assets. Please try again later.');
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getHistoricalData = async (symbol: string, startDate: Date, endDate: Date) => {
|
||||
try {
|
||||
const start = Math.floor(startDate.getTime() / 1000);
|
||||
const end = Math.floor(endDate.getTime() / 1000);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
period1: start.toString(),
|
||||
period2: end.toString(),
|
||||
interval: '1d',
|
||||
});
|
||||
|
||||
const url = `${API_BASE}/v8/finance/chart/${symbol}${!isDev ? encodeURIComponent(`?${params}`) : `?${params}`}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
|
||||
const data = await response.json();
|
||||
const { timestamp, indicators, meta } = data.chart.result[0] as YahooChartResult;
|
||||
const quotes = indicators.quote[0];
|
||||
|
||||
return {
|
||||
historicalData: new Map(timestamp.map((time: number, index: number) => [formatDateToISO(new Date(time * 1000)), quotes.close[index]])),
|
||||
longName: meta.longName
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching historical data:', error);
|
||||
toast.error(`Failed to fetch historical data for ${symbol}. Please try again later.`);
|
||||
return { historicalData: new Map<string, number>(), longName: '' };
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,210 +1,210 @@
|
|||
export interface Asset {
|
||||
id: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
quoteType: string;
|
||||
price?: string;
|
||||
priceChange?: string;
|
||||
priceChangePercent?: string;
|
||||
rank: string;
|
||||
wkn: string;
|
||||
symbol: string;
|
||||
historicalData: Map<string, number>;
|
||||
investments: Investment[];
|
||||
}
|
||||
|
||||
export interface Investment {
|
||||
id: string;
|
||||
assetId: string;
|
||||
type: 'single' | 'periodic';
|
||||
amount: number;
|
||||
date?: Date;
|
||||
periodicGroupId?: string;
|
||||
}
|
||||
|
||||
export interface PeriodicSettings {
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
intervalUnit: 'days' | 'weeks' | 'months' | 'quarters' | 'years';
|
||||
startDate: Date;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
yearInterval: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InvestmentPerformance {
|
||||
id: string;
|
||||
assetName: string;
|
||||
date: Date;
|
||||
investedAmount: number;
|
||||
investedAtPrice: number;
|
||||
currentValue: number;
|
||||
performancePercentage: number;
|
||||
periodicGroupId?: string;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export interface InvestmentPerformance {
|
||||
id: string;
|
||||
assetName: string;
|
||||
date: Date;
|
||||
investedAmount: number;
|
||||
investedAtPrice: number;
|
||||
currentValue: number;
|
||||
performancePercentage: number;
|
||||
}
|
||||
|
||||
export interface PortfolioPerformance {
|
||||
investments: InvestmentPerformance[];
|
||||
summary: {
|
||||
totalInvested: number;
|
||||
currentValue: number;
|
||||
annualPerformancesPerAsset: Map<string, { year: number; percentage: number; price: number }[]>;
|
||||
performancePercentage: number;
|
||||
performancePerAnnoPerformance: number;
|
||||
ttworValue: number;
|
||||
ttworPercentage: number;
|
||||
bestPerformancePerAnno: { percentage: number, year: number }[];
|
||||
worstPerformancePerAnno: { percentage: number, year: number }[];
|
||||
annualPerformances: { year: number; percentage: number; }[];
|
||||
};
|
||||
}
|
||||
|
||||
export type DayData = {
|
||||
date: Date;
|
||||
total: number;
|
||||
invested: number;
|
||||
percentageChange: number;
|
||||
/* Current price of asset */
|
||||
assets: { [key: string]: number };
|
||||
};
|
||||
|
||||
export interface WithdrawalPlan {
|
||||
amount: number;
|
||||
interval: 'monthly' | 'yearly';
|
||||
startTrigger: 'date' | 'portfolioValue' | 'auto';
|
||||
startDate?: Date;
|
||||
startPortfolioValue?: number;
|
||||
enabled: boolean;
|
||||
autoStrategy?: {
|
||||
type: 'maintain' | 'deplete' | 'grow';
|
||||
targetYears?: number;
|
||||
targetGrowth?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectionData {
|
||||
date: Date;
|
||||
value: number;
|
||||
invested: number;
|
||||
withdrawals: number;
|
||||
totalWithdrawn: number;
|
||||
}
|
||||
|
||||
export interface SustainabilityAnalysis {
|
||||
yearsToReachTarget: number;
|
||||
targetValue: number;
|
||||
sustainableYears: number | 'infinite';
|
||||
}
|
||||
|
||||
export interface PeriodicSettings {
|
||||
startDate: Date;
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
amount: number;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
yearInterval: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface YahooQuoteDocument {
|
||||
symbol: string;
|
||||
shortName: string;
|
||||
rank: string;
|
||||
regularMarketPrice: {
|
||||
raw: number;
|
||||
fmt: string;
|
||||
};
|
||||
regularMarketChange: {
|
||||
raw: number;
|
||||
fmt: string;
|
||||
};
|
||||
regularMarketPercentChange: {
|
||||
raw: number;
|
||||
fmt: string;
|
||||
};
|
||||
exchange: string;
|
||||
quoteType: string;
|
||||
}
|
||||
|
||||
export interface YahooSearchResponse {
|
||||
finance: {
|
||||
result: [{
|
||||
documents: YahooQuoteDocument[];
|
||||
}];
|
||||
error: null | string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface YahooChartResult {
|
||||
timestamp: number[];
|
||||
meta: {
|
||||
currency: string;
|
||||
symbol: string;
|
||||
exchangeName: string;
|
||||
fullExchangeName: string;
|
||||
instrumentType: string;
|
||||
firstTradeDate: number;
|
||||
regularMarketTime: number;
|
||||
hasPrePostMarketData: boolean;
|
||||
gmtoffset: number;
|
||||
timezone: string;
|
||||
exchangeTimezoneName: string;
|
||||
regularMarketPrice: number;
|
||||
fiftyTwoWeekHigh: number;
|
||||
fiftyTwoWeekLow: number;
|
||||
regularMarketDayHigh: number;
|
||||
regularMarketDayLow: number;
|
||||
regularMarketVolume: number;
|
||||
longName: string;
|
||||
shortName: string;
|
||||
chartPreviousClose: number;
|
||||
priceHint: number;
|
||||
currentTradingPeriod: {
|
||||
pre: {
|
||||
timezone: string;
|
||||
start: number;
|
||||
end: number;
|
||||
gmtoffset: number;
|
||||
};
|
||||
regular: {
|
||||
timezone: string;
|
||||
start: number;
|
||||
end: number;
|
||||
gmtoffset: number;
|
||||
};
|
||||
post: {
|
||||
timezone: string;
|
||||
start: number;
|
||||
end: number;
|
||||
gmtoffset: number;
|
||||
};
|
||||
};
|
||||
dataGranularity: string;
|
||||
range: string;
|
||||
validRanges: string[];
|
||||
}
|
||||
indicators: {
|
||||
quote: [{
|
||||
close: number[];
|
||||
}];
|
||||
};
|
||||
}
|
||||
export interface Asset {
|
||||
id: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
quoteType: string;
|
||||
price?: string;
|
||||
priceChange?: string;
|
||||
priceChangePercent?: string;
|
||||
rank: string;
|
||||
wkn: string;
|
||||
symbol: string;
|
||||
historicalData: Map<string, number>;
|
||||
investments: Investment[];
|
||||
}
|
||||
|
||||
export interface Investment {
|
||||
id: string;
|
||||
assetId: string;
|
||||
type: 'single' | 'periodic';
|
||||
amount: number;
|
||||
date?: Date;
|
||||
periodicGroupId?: string;
|
||||
}
|
||||
|
||||
export interface PeriodicSettings {
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
intervalUnit: 'days' | 'weeks' | 'months' | 'quarters' | 'years';
|
||||
startDate: Date;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
yearInterval: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InvestmentPerformance {
|
||||
id: string;
|
||||
assetName: string;
|
||||
date: Date;
|
||||
investedAmount: number;
|
||||
investedAtPrice: number;
|
||||
currentValue: number;
|
||||
performancePercentage: number;
|
||||
periodicGroupId?: string;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export interface InvestmentPerformance {
|
||||
id: string;
|
||||
assetName: string;
|
||||
date: Date;
|
||||
investedAmount: number;
|
||||
investedAtPrice: number;
|
||||
currentValue: number;
|
||||
performancePercentage: number;
|
||||
}
|
||||
|
||||
export interface PortfolioPerformance {
|
||||
investments: InvestmentPerformance[];
|
||||
summary: {
|
||||
totalInvested: number;
|
||||
currentValue: number;
|
||||
annualPerformancesPerAsset: Map<string, { year: number; percentage: number; price: number }[]>;
|
||||
performancePercentage: number;
|
||||
performancePerAnnoPerformance: number;
|
||||
ttworValue: number;
|
||||
ttworPercentage: number;
|
||||
bestPerformancePerAnno: { percentage: number, year: number }[];
|
||||
worstPerformancePerAnno: { percentage: number, year: number }[];
|
||||
annualPerformances: { year: number; percentage: number; }[];
|
||||
};
|
||||
}
|
||||
|
||||
export type DayData = {
|
||||
date: Date;
|
||||
total: number;
|
||||
invested: number;
|
||||
percentageChange: number;
|
||||
/* Current price of asset */
|
||||
assets: { [key: string]: number };
|
||||
};
|
||||
|
||||
export interface WithdrawalPlan {
|
||||
amount: number;
|
||||
interval: 'monthly' | 'yearly';
|
||||
startTrigger: 'date' | 'portfolioValue' | 'auto';
|
||||
startDate?: Date;
|
||||
startPortfolioValue?: number;
|
||||
enabled: boolean;
|
||||
autoStrategy?: {
|
||||
type: 'maintain' | 'deplete' | 'grow';
|
||||
targetYears?: number;
|
||||
targetGrowth?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProjectionData {
|
||||
date: Date;
|
||||
value: number;
|
||||
invested: number;
|
||||
withdrawals: number;
|
||||
totalWithdrawn: number;
|
||||
}
|
||||
|
||||
export interface SustainabilityAnalysis {
|
||||
yearsToReachTarget: number;
|
||||
targetValue: number;
|
||||
sustainableYears: number | 'infinite';
|
||||
}
|
||||
|
||||
export interface PeriodicSettings {
|
||||
startDate: Date;
|
||||
dayOfMonth: number;
|
||||
interval: number;
|
||||
amount: number;
|
||||
dynamic?: {
|
||||
type: 'percentage' | 'fixed';
|
||||
value: number;
|
||||
yearInterval: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface YahooQuoteDocument {
|
||||
symbol: string;
|
||||
shortName: string;
|
||||
rank: string;
|
||||
regularMarketPrice: {
|
||||
raw: number;
|
||||
fmt: string;
|
||||
};
|
||||
regularMarketChange: {
|
||||
raw: number;
|
||||
fmt: string;
|
||||
};
|
||||
regularMarketPercentChange: {
|
||||
raw: number;
|
||||
fmt: string;
|
||||
};
|
||||
exchange: string;
|
||||
quoteType: string;
|
||||
}
|
||||
|
||||
export interface YahooSearchResponse {
|
||||
finance: {
|
||||
result: [{
|
||||
documents: YahooQuoteDocument[];
|
||||
}];
|
||||
error: null | string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface YahooChartResult {
|
||||
timestamp: number[];
|
||||
meta: {
|
||||
currency: string;
|
||||
symbol: string;
|
||||
exchangeName: string;
|
||||
fullExchangeName: string;
|
||||
instrumentType: string;
|
||||
firstTradeDate: number;
|
||||
regularMarketTime: number;
|
||||
hasPrePostMarketData: boolean;
|
||||
gmtoffset: number;
|
||||
timezone: string;
|
||||
exchangeTimezoneName: string;
|
||||
regularMarketPrice: number;
|
||||
fiftyTwoWeekHigh: number;
|
||||
fiftyTwoWeekLow: number;
|
||||
regularMarketDayHigh: number;
|
||||
regularMarketDayLow: number;
|
||||
regularMarketVolume: number;
|
||||
longName: string;
|
||||
shortName: string;
|
||||
chartPreviousClose: number;
|
||||
priceHint: number;
|
||||
currentTradingPeriod: {
|
||||
pre: {
|
||||
timezone: string;
|
||||
start: number;
|
||||
end: number;
|
||||
gmtoffset: number;
|
||||
};
|
||||
regular: {
|
||||
timezone: string;
|
||||
start: number;
|
||||
end: number;
|
||||
gmtoffset: number;
|
||||
};
|
||||
post: {
|
||||
timezone: string;
|
||||
start: number;
|
||||
end: number;
|
||||
gmtoffset: number;
|
||||
};
|
||||
};
|
||||
dataGranularity: string;
|
||||
range: string;
|
||||
validRanges: string[];
|
||||
}
|
||||
indicators: {
|
||||
quote: [{
|
||||
close: number[];
|
||||
}];
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,123 +1,123 @@
|
|||
import { addDays, addMonths, addWeeks, addYears, isAfter, isSameDay, setDate } from "date-fns";
|
||||
|
||||
import { formatDateToISO } from "../formatters";
|
||||
|
||||
import type { Asset, Investment, PeriodicSettings } from "../../types";
|
||||
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
|
||||
let investmentPrice = asset.historicalData.get(formatDateToISO(invDate)) || 0;
|
||||
// if no investment price found, try to find the nearest price
|
||||
if(!investmentPrice) {
|
||||
let previousDate = invDate;
|
||||
let afterDate = invDate;
|
||||
while(!investmentPrice) {
|
||||
previousDate = addDays(previousDate, -1);
|
||||
afterDate = addDays(afterDate, 1);
|
||||
investmentPrice = asset.historicalData.get(formatDateToISO(previousDate)) || asset.historicalData.get(formatDateToISO(afterDate)) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (investmentPrice > 0) {
|
||||
totalShares += investment.amount / investmentPrice;
|
||||
buyIns.push(investmentPrice);
|
||||
}
|
||||
}
|
||||
|
||||
// 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[] = [];
|
||||
const periodicGroupId = crypto.randomUUID();
|
||||
|
||||
// Create UTC dates
|
||||
let currentDate = new Date(Date.UTC(
|
||||
settings.startDate.getUTCFullYear(),
|
||||
settings.startDate.getUTCMonth(),
|
||||
settings.startDate.getUTCDate()
|
||||
));
|
||||
|
||||
const end = new Date(Date.UTC(
|
||||
endDate.getUTCFullYear(),
|
||||
endDate.getUTCMonth(),
|
||||
endDate.getUTCDate()
|
||||
));
|
||||
|
||||
let currentAmount = settings.amount;
|
||||
|
||||
while (currentDate <= end) {
|
||||
// For monthly/yearly intervals, ensure we're on the correct day of month
|
||||
if (settings.intervalUnit !== 'days') {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
|
||||
// Only add investment if we haven't passed the end date
|
||||
if (currentDate <= end) {
|
||||
// Handle dynamic increases if configured
|
||||
if (settings.dynamic) {
|
||||
const yearsSinceStart =
|
||||
(currentDate.getTime() - settings.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24 * 365);
|
||||
|
||||
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
|
||||
if (settings.dynamic.type === 'percentage') {
|
||||
currentAmount *= (1 + (settings.dynamic.value / 100));
|
||||
} else {
|
||||
currentAmount += settings.dynamic.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
investments.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'periodic',
|
||||
amount: currentAmount,
|
||||
date: currentDate,
|
||||
periodicGroupId,
|
||||
assetId
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate next date based on interval unit
|
||||
switch (settings.intervalUnit) {
|
||||
case 'days':
|
||||
currentDate = addDays(currentDate, settings.interval);
|
||||
break;
|
||||
case 'weeks':
|
||||
currentDate = addWeeks(currentDate, settings.interval);
|
||||
break;
|
||||
case 'months':
|
||||
currentDate = addMonths(currentDate, settings.interval);
|
||||
// Ensure we maintain the correct day of month using UTC
|
||||
if (currentDate.getUTCDate() !== settings.dayOfMonth) {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
break;
|
||||
case 'quarters':
|
||||
currentDate = addMonths(currentDate, settings.interval * 3);
|
||||
break;
|
||||
case 'years':
|
||||
currentDate = addYears(currentDate, settings.interval);
|
||||
// Ensure we maintain the correct day of month using UTC
|
||||
if (currentDate.getUTCDate() !== settings.dayOfMonth) {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return investments;
|
||||
};
|
||||
import { addDays, addMonths, addWeeks, addYears, isAfter, isSameDay, setDate } from "date-fns";
|
||||
|
||||
import { formatDateToISO } from "../formatters";
|
||||
|
||||
import type { Asset, Investment, PeriodicSettings } from "../../types";
|
||||
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
|
||||
let investmentPrice = asset.historicalData.get(formatDateToISO(invDate)) || 0;
|
||||
// if no investment price found, try to find the nearest price
|
||||
if(!investmentPrice) {
|
||||
let previousDate = invDate;
|
||||
let afterDate = invDate;
|
||||
while(!investmentPrice) {
|
||||
previousDate = addDays(previousDate, -1);
|
||||
afterDate = addDays(afterDate, 1);
|
||||
investmentPrice = asset.historicalData.get(formatDateToISO(previousDate)) || asset.historicalData.get(formatDateToISO(afterDate)) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (investmentPrice > 0) {
|
||||
totalShares += investment.amount / investmentPrice;
|
||||
buyIns.push(investmentPrice);
|
||||
}
|
||||
}
|
||||
|
||||
// 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[] = [];
|
||||
const periodicGroupId = crypto.randomUUID();
|
||||
|
||||
// Create UTC dates
|
||||
let currentDate = new Date(Date.UTC(
|
||||
settings.startDate.getUTCFullYear(),
|
||||
settings.startDate.getUTCMonth(),
|
||||
settings.startDate.getUTCDate()
|
||||
));
|
||||
|
||||
const end = new Date(Date.UTC(
|
||||
endDate.getUTCFullYear(),
|
||||
endDate.getUTCMonth(),
|
||||
endDate.getUTCDate()
|
||||
));
|
||||
|
||||
let currentAmount = settings.amount;
|
||||
|
||||
while (currentDate <= end) {
|
||||
// For monthly/yearly intervals, ensure we're on the correct day of month
|
||||
if (settings.intervalUnit !== 'days') {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
|
||||
// Only add investment if we haven't passed the end date
|
||||
if (currentDate <= end) {
|
||||
// Handle dynamic increases if configured
|
||||
if (settings.dynamic) {
|
||||
const yearsSinceStart =
|
||||
(currentDate.getTime() - settings.startDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24 * 365);
|
||||
|
||||
if (yearsSinceStart > 0 && yearsSinceStart % settings.dynamic.yearInterval === 0) {
|
||||
if (settings.dynamic.type === 'percentage') {
|
||||
currentAmount *= (1 + (settings.dynamic.value / 100));
|
||||
} else {
|
||||
currentAmount += settings.dynamic.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
investments.push({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'periodic',
|
||||
amount: currentAmount,
|
||||
date: currentDate,
|
||||
periodicGroupId,
|
||||
assetId
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate next date based on interval unit
|
||||
switch (settings.intervalUnit) {
|
||||
case 'days':
|
||||
currentDate = addDays(currentDate, settings.interval);
|
||||
break;
|
||||
case 'weeks':
|
||||
currentDate = addWeeks(currentDate, settings.interval);
|
||||
break;
|
||||
case 'months':
|
||||
currentDate = addMonths(currentDate, settings.interval);
|
||||
// Ensure we maintain the correct day of month using UTC
|
||||
if (currentDate.getUTCDate() !== settings.dayOfMonth) {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
break;
|
||||
case 'quarters':
|
||||
currentDate = addMonths(currentDate, settings.interval * 3);
|
||||
break;
|
||||
case 'years':
|
||||
currentDate = addYears(currentDate, settings.interval);
|
||||
// Ensure we maintain the correct day of month using UTC
|
||||
if (currentDate.getUTCDate() !== settings.dayOfMonth) {
|
||||
currentDate = setDate(currentDate, settings.dayOfMonth);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return investments;
|
||||
};
|
||||
|
|
|
@ -1,255 +1,255 @@
|
|||
import { addMonths, differenceInYears } from "date-fns";
|
||||
|
||||
import type {
|
||||
ProjectionData, SustainabilityAnalysis, WithdrawalPlan, Asset, Investment
|
||||
} from "../../types";
|
||||
|
||||
const findOptimalStartingPoint = (
|
||||
currentPortfolioValue: number,
|
||||
monthlyGrowth: number,
|
||||
desiredWithdrawal: number,
|
||||
strategy: WithdrawalPlan['autoStrategy'],
|
||||
interval: 'monthly' | 'yearly'
|
||||
): { startDate: Date; requiredPortfolioValue: number } => {
|
||||
const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal;
|
||||
let requiredPortfolioValue = 0;
|
||||
|
||||
// Declare variables outside switch
|
||||
const months = (strategy?.targetYears || 30) * 12;
|
||||
const r = monthlyGrowth;
|
||||
const targetGrowth = (strategy?.targetGrowth || 2) / 100;
|
||||
const targetMonthlyGrowth = Math.pow(1 + targetGrowth, 1 / 12) - 1;
|
||||
|
||||
switch (strategy?.type) {
|
||||
case 'maintain':
|
||||
requiredPortfolioValue = monthlyWithdrawal / monthlyGrowth;
|
||||
break;
|
||||
case 'deplete':
|
||||
requiredPortfolioValue = (monthlyWithdrawal * (Math.pow(1 + r, months) - 1)) / (r * Math.pow(1 + r, months));
|
||||
break;
|
||||
case 'grow':
|
||||
requiredPortfolioValue = monthlyWithdrawal / (monthlyGrowth - targetMonthlyGrowth);
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate when we'll reach the required value
|
||||
const monthsToReach = Math.ceil(
|
||||
Math.log(requiredPortfolioValue / currentPortfolioValue) /
|
||||
Math.log(1 + monthlyGrowth)
|
||||
);
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach));
|
||||
|
||||
return {
|
||||
startDate,
|
||||
requiredPortfolioValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateFutureProjection = async (
|
||||
currentAssets: Asset[],
|
||||
yearsToProject: number,
|
||||
annualReturnRate: number,
|
||||
withdrawalPlan?: WithdrawalPlan,
|
||||
startFromZero: boolean = false
|
||||
): Promise<{
|
||||
projection: ProjectionData[];
|
||||
sustainability: SustainabilityAnalysis;
|
||||
}> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const projectionData: ProjectionData[] = [];
|
||||
const maxProjectionYears = 100; // Project up to 100 years to find true sustainability
|
||||
const endDateForDisplay = addMonths(new Date(), yearsToProject * 12);
|
||||
const endDateForCalculation = addMonths(new Date(), maxProjectionYears * 12);
|
||||
|
||||
// Get all periodic investment patterns
|
||||
const periodicInvestments = currentAssets.flatMap(asset => {
|
||||
const patterns = new Map<string, Investment[]>();
|
||||
|
||||
// When startFromZero is true, only include periodic investments
|
||||
asset.investments.forEach(inv => {
|
||||
if (inv.type === 'periodic' && inv.periodicGroupId) {
|
||||
if (!patterns.has(inv.periodicGroupId)) {
|
||||
patterns.set(inv.periodicGroupId, []);
|
||||
}
|
||||
patterns.get(inv.periodicGroupId)!.push(inv);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(patterns.values())
|
||||
.map(group => ({
|
||||
pattern: group.sort((a, b) =>
|
||||
new Date(a.date!).getTime() - new Date(b.date!).getTime()
|
||||
)
|
||||
}));
|
||||
});
|
||||
|
||||
// Project future investments
|
||||
const futureInvestments = periodicInvestments.flatMap(({ pattern }) => {
|
||||
if (pattern.length < 2) return [];
|
||||
|
||||
const lastInvestment = pattern[pattern.length - 1];
|
||||
const secondLastInvestment = pattern[pattern.length - 2];
|
||||
|
||||
const interval = new Date(lastInvestment.date!).getTime() -
|
||||
new Date(secondLastInvestment.date!).getTime();
|
||||
const amountDiff = lastInvestment.amount - secondLastInvestment.amount;
|
||||
|
||||
const future: Investment[] = [];
|
||||
let currentDate = new Date(lastInvestment.date!);
|
||||
let currentAmount = lastInvestment.amount;
|
||||
|
||||
while (currentDate <= endDateForCalculation) {
|
||||
currentDate = new Date(currentDate.getTime() + interval);
|
||||
currentAmount += amountDiff;
|
||||
|
||||
future.push({
|
||||
...lastInvestment,
|
||||
date: currentDate,
|
||||
amount: currentAmount,
|
||||
});
|
||||
}
|
||||
|
||||
return future;
|
||||
});
|
||||
|
||||
// Calculate monthly values
|
||||
let currentDate = new Date();
|
||||
|
||||
// Initialize totalInvested based on startFromZero flag
|
||||
let totalInvested = 0;
|
||||
if (!startFromZero) {
|
||||
// Include all investments if not starting from zero
|
||||
totalInvested = currentAssets.reduce(
|
||||
(sum, asset) => sum + asset.investments.reduce(
|
||||
(assetSum, inv) => assetSum + inv.amount, 0
|
||||
), 0
|
||||
);
|
||||
} else {
|
||||
// Start from zero when startFromZero is true
|
||||
totalInvested = 0;
|
||||
// Don't initialize with any periodic investments - they'll accumulate over time
|
||||
}
|
||||
|
||||
let totalWithdrawn = 0;
|
||||
let yearsToReachTarget = 0;
|
||||
let targetValue = 0;
|
||||
let sustainableYears: number | 'infinite' = 'infinite';
|
||||
let portfolioValue = startFromZero ? 0 : totalInvested; // Start from 0 if startFromZero is true
|
||||
let withdrawalsStarted = false;
|
||||
let withdrawalStartDate: Date | null = null;
|
||||
let portfolioDepletionDate: Date | null = null;
|
||||
|
||||
// Calculate optimal withdrawal plan if auto strategy is selected
|
||||
if (withdrawalPlan?.enabled && withdrawalPlan.startTrigger === 'auto') {
|
||||
const { startDate, requiredPortfolioValue } = findOptimalStartingPoint(
|
||||
portfolioValue,
|
||||
Math.pow(1 + annualReturnRate / 100, 1 / 12) - 1,
|
||||
withdrawalPlan.amount,
|
||||
withdrawalPlan.autoStrategy,
|
||||
withdrawalPlan.interval
|
||||
);
|
||||
|
||||
withdrawalPlan.startDate = startDate;
|
||||
withdrawalPlan.startPortfolioValue = requiredPortfolioValue;
|
||||
}
|
||||
|
||||
while (currentDate <= endDateForCalculation) {
|
||||
// Check if withdrawals should start
|
||||
if (!withdrawalsStarted && withdrawalPlan?.enabled) {
|
||||
withdrawalsStarted = withdrawalPlan.startTrigger === 'date'
|
||||
? new Date(currentDate) >= new Date(withdrawalPlan.startDate!)
|
||||
: portfolioValue >= (withdrawalPlan.startPortfolioValue || 0);
|
||||
|
||||
if (withdrawalsStarted) {
|
||||
withdrawalStartDate = new Date(currentDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle monthly growth if portfolio isn't depleted
|
||||
if (portfolioValue > 0) {
|
||||
const monthlyReturn = Math.pow(1 + annualReturnRate / 100, 1 / 12) - 1;
|
||||
portfolioValue *= (1 + monthlyReturn);
|
||||
}
|
||||
|
||||
// Add new investments only if withdrawals haven't started
|
||||
if (!withdrawalsStarted) {
|
||||
const monthInvestments = futureInvestments.filter(
|
||||
inv => new Date(inv.date!).getMonth() === currentDate.getMonth() &&
|
||||
new Date(inv.date!).getFullYear() === currentDate.getFullYear()
|
||||
);
|
||||
|
||||
const monthlyInvestment = monthInvestments.reduce(
|
||||
(sum, inv) => sum + inv.amount, 0
|
||||
);
|
||||
|
||||
// Always add periodic investments to both totalInvested and portfolioValue
|
||||
totalInvested += monthlyInvestment;
|
||||
portfolioValue += monthlyInvestment;
|
||||
}
|
||||
|
||||
// Handle withdrawals
|
||||
let monthlyWithdrawal = 0;
|
||||
if (withdrawalsStarted && portfolioValue > 0) {
|
||||
monthlyWithdrawal = withdrawalPlan!.interval === 'monthly'
|
||||
? withdrawalPlan!.amount
|
||||
: (currentDate.getMonth() === 0 ? withdrawalPlan!.amount : 0);
|
||||
|
||||
portfolioValue -= monthlyWithdrawal;
|
||||
if (portfolioValue < 0) {
|
||||
monthlyWithdrawal += portfolioValue; // Adjust final withdrawal
|
||||
portfolioValue = 0;
|
||||
if (sustainableYears === 'infinite') {
|
||||
sustainableYears = differenceInYears(currentDate, withdrawalStartDate!);
|
||||
}
|
||||
}
|
||||
totalWithdrawn += monthlyWithdrawal;
|
||||
}
|
||||
|
||||
// Update target metrics
|
||||
if (withdrawalsStarted && !targetValue) {
|
||||
targetValue = portfolioValue;
|
||||
yearsToReachTarget = differenceInYears(currentDate, new Date());
|
||||
}
|
||||
|
||||
if (portfolioValue <= 0 && !portfolioDepletionDate) {
|
||||
portfolioDepletionDate = new Date(currentDate);
|
||||
}
|
||||
|
||||
// Only add to projection data if within display timeframe
|
||||
if (currentDate <= endDateForDisplay) {
|
||||
projectionData.push({
|
||||
date: currentDate,
|
||||
value: Math.max(0, portfolioValue),
|
||||
invested: totalInvested,
|
||||
withdrawals: monthlyWithdrawal,
|
||||
totalWithdrawn,
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = addMonths(currentDate, 1);
|
||||
}
|
||||
|
||||
// Calculate actual sustainability duration
|
||||
let actualSustainableYears: number | 'infinite' = 'infinite';
|
||||
if (portfolioDepletionDate) {
|
||||
actualSustainableYears = differenceInYears(
|
||||
portfolioDepletionDate,
|
||||
withdrawalStartDate || new Date()
|
||||
);
|
||||
} else if (portfolioValue > 0) {
|
||||
// If portfolio is still growing after maxProjectionYears, it's truly sustainable
|
||||
actualSustainableYears = 'infinite';
|
||||
}
|
||||
|
||||
return {
|
||||
projection: projectionData,
|
||||
sustainability: {
|
||||
yearsToReachTarget,
|
||||
targetValue,
|
||||
sustainableYears: actualSustainableYears,
|
||||
},
|
||||
};
|
||||
};
|
||||
import { addMonths, differenceInYears } from "date-fns";
|
||||
|
||||
import type {
|
||||
ProjectionData, SustainabilityAnalysis, WithdrawalPlan, Asset, Investment
|
||||
} from "../../types";
|
||||
|
||||
const findOptimalStartingPoint = (
|
||||
currentPortfolioValue: number,
|
||||
monthlyGrowth: number,
|
||||
desiredWithdrawal: number,
|
||||
strategy: WithdrawalPlan['autoStrategy'],
|
||||
interval: 'monthly' | 'yearly'
|
||||
): { startDate: Date; requiredPortfolioValue: number } => {
|
||||
const monthlyWithdrawal = interval === 'yearly' ? desiredWithdrawal / 12 : desiredWithdrawal;
|
||||
let requiredPortfolioValue = 0;
|
||||
|
||||
// Declare variables outside switch
|
||||
const months = (strategy?.targetYears || 30) * 12;
|
||||
const r = monthlyGrowth;
|
||||
const targetGrowth = (strategy?.targetGrowth || 2) / 100;
|
||||
const targetMonthlyGrowth = Math.pow(1 + targetGrowth, 1 / 12) - 1;
|
||||
|
||||
switch (strategy?.type) {
|
||||
case 'maintain':
|
||||
requiredPortfolioValue = monthlyWithdrawal / monthlyGrowth;
|
||||
break;
|
||||
case 'deplete':
|
||||
requiredPortfolioValue = (monthlyWithdrawal * (Math.pow(1 + r, months) - 1)) / (r * Math.pow(1 + r, months));
|
||||
break;
|
||||
case 'grow':
|
||||
requiredPortfolioValue = monthlyWithdrawal / (monthlyGrowth - targetMonthlyGrowth);
|
||||
break;
|
||||
}
|
||||
|
||||
// Calculate when we'll reach the required value
|
||||
const monthsToReach = Math.ceil(
|
||||
Math.log(requiredPortfolioValue / currentPortfolioValue) /
|
||||
Math.log(1 + monthlyGrowth)
|
||||
);
|
||||
|
||||
const startDate = new Date();
|
||||
startDate.setMonth(startDate.getMonth() + Math.max(0, monthsToReach));
|
||||
|
||||
return {
|
||||
startDate,
|
||||
requiredPortfolioValue,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateFutureProjection = async (
|
||||
currentAssets: Asset[],
|
||||
yearsToProject: number,
|
||||
annualReturnRate: number,
|
||||
withdrawalPlan?: WithdrawalPlan,
|
||||
startFromZero: boolean = false
|
||||
): Promise<{
|
||||
projection: ProjectionData[];
|
||||
sustainability: SustainabilityAnalysis;
|
||||
}> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const projectionData: ProjectionData[] = [];
|
||||
const maxProjectionYears = 100; // Project up to 100 years to find true sustainability
|
||||
const endDateForDisplay = addMonths(new Date(), yearsToProject * 12);
|
||||
const endDateForCalculation = addMonths(new Date(), maxProjectionYears * 12);
|
||||
|
||||
// Get all periodic investment patterns
|
||||
const periodicInvestments = currentAssets.flatMap(asset => {
|
||||
const patterns = new Map<string, Investment[]>();
|
||||
|
||||
// When startFromZero is true, only include periodic investments
|
||||
asset.investments.forEach(inv => {
|
||||
if (inv.type === 'periodic' && inv.periodicGroupId) {
|
||||
if (!patterns.has(inv.periodicGroupId)) {
|
||||
patterns.set(inv.periodicGroupId, []);
|
||||
}
|
||||
patterns.get(inv.periodicGroupId)!.push(inv);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(patterns.values())
|
||||
.map(group => ({
|
||||
pattern: group.sort((a, b) =>
|
||||
new Date(a.date!).getTime() - new Date(b.date!).getTime()
|
||||
)
|
||||
}));
|
||||
});
|
||||
|
||||
// Project future investments
|
||||
const futureInvestments = periodicInvestments.flatMap(({ pattern }) => {
|
||||
if (pattern.length < 2) return [];
|
||||
|
||||
const lastInvestment = pattern[pattern.length - 1];
|
||||
const secondLastInvestment = pattern[pattern.length - 2];
|
||||
|
||||
const interval = new Date(lastInvestment.date!).getTime() -
|
||||
new Date(secondLastInvestment.date!).getTime();
|
||||
const amountDiff = lastInvestment.amount - secondLastInvestment.amount;
|
||||
|
||||
const future: Investment[] = [];
|
||||
let currentDate = new Date(lastInvestment.date!);
|
||||
let currentAmount = lastInvestment.amount;
|
||||
|
||||
while (currentDate <= endDateForCalculation) {
|
||||
currentDate = new Date(currentDate.getTime() + interval);
|
||||
currentAmount += amountDiff;
|
||||
|
||||
future.push({
|
||||
...lastInvestment,
|
||||
date: currentDate,
|
||||
amount: currentAmount,
|
||||
});
|
||||
}
|
||||
|
||||
return future;
|
||||
});
|
||||
|
||||
// Calculate monthly values
|
||||
let currentDate = new Date();
|
||||
|
||||
// Initialize totalInvested based on startFromZero flag
|
||||
let totalInvested = 0;
|
||||
if (!startFromZero) {
|
||||
// Include all investments if not starting from zero
|
||||
totalInvested = currentAssets.reduce(
|
||||
(sum, asset) => sum + asset.investments.reduce(
|
||||
(assetSum, inv) => assetSum + inv.amount, 0
|
||||
), 0
|
||||
);
|
||||
} else {
|
||||
// Start from zero when startFromZero is true
|
||||
totalInvested = 0;
|
||||
// Don't initialize with any periodic investments - they'll accumulate over time
|
||||
}
|
||||
|
||||
let totalWithdrawn = 0;
|
||||
let yearsToReachTarget = 0;
|
||||
let targetValue = 0;
|
||||
let sustainableYears: number | 'infinite' = 'infinite';
|
||||
let portfolioValue = startFromZero ? 0 : totalInvested; // Start from 0 if startFromZero is true
|
||||
let withdrawalsStarted = false;
|
||||
let withdrawalStartDate: Date | null = null;
|
||||
let portfolioDepletionDate: Date | null = null;
|
||||
|
||||
// Calculate optimal withdrawal plan if auto strategy is selected
|
||||
if (withdrawalPlan?.enabled && withdrawalPlan.startTrigger === 'auto') {
|
||||
const { startDate, requiredPortfolioValue } = findOptimalStartingPoint(
|
||||
portfolioValue,
|
||||
Math.pow(1 + annualReturnRate / 100, 1 / 12) - 1,
|
||||
withdrawalPlan.amount,
|
||||
withdrawalPlan.autoStrategy,
|
||||
withdrawalPlan.interval
|
||||
);
|
||||
|
||||
withdrawalPlan.startDate = startDate;
|
||||
withdrawalPlan.startPortfolioValue = requiredPortfolioValue;
|
||||
}
|
||||
|
||||
while (currentDate <= endDateForCalculation) {
|
||||
// Check if withdrawals should start
|
||||
if (!withdrawalsStarted && withdrawalPlan?.enabled) {
|
||||
withdrawalsStarted = withdrawalPlan.startTrigger === 'date'
|
||||
? new Date(currentDate) >= new Date(withdrawalPlan.startDate!)
|
||||
: portfolioValue >= (withdrawalPlan.startPortfolioValue || 0);
|
||||
|
||||
if (withdrawalsStarted) {
|
||||
withdrawalStartDate = new Date(currentDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle monthly growth if portfolio isn't depleted
|
||||
if (portfolioValue > 0) {
|
||||
const monthlyReturn = Math.pow(1 + annualReturnRate / 100, 1 / 12) - 1;
|
||||
portfolioValue *= (1 + monthlyReturn);
|
||||
}
|
||||
|
||||
// Add new investments only if withdrawals haven't started
|
||||
if (!withdrawalsStarted) {
|
||||
const monthInvestments = futureInvestments.filter(
|
||||
inv => new Date(inv.date!).getMonth() === currentDate.getMonth() &&
|
||||
new Date(inv.date!).getFullYear() === currentDate.getFullYear()
|
||||
);
|
||||
|
||||
const monthlyInvestment = monthInvestments.reduce(
|
||||
(sum, inv) => sum + inv.amount, 0
|
||||
);
|
||||
|
||||
// Always add periodic investments to both totalInvested and portfolioValue
|
||||
totalInvested += monthlyInvestment;
|
||||
portfolioValue += monthlyInvestment;
|
||||
}
|
||||
|
||||
// Handle withdrawals
|
||||
let monthlyWithdrawal = 0;
|
||||
if (withdrawalsStarted && portfolioValue > 0) {
|
||||
monthlyWithdrawal = withdrawalPlan!.interval === 'monthly'
|
||||
? withdrawalPlan!.amount
|
||||
: (currentDate.getMonth() === 0 ? withdrawalPlan!.amount : 0);
|
||||
|
||||
portfolioValue -= monthlyWithdrawal;
|
||||
if (portfolioValue < 0) {
|
||||
monthlyWithdrawal += portfolioValue; // Adjust final withdrawal
|
||||
portfolioValue = 0;
|
||||
if (sustainableYears === 'infinite') {
|
||||
sustainableYears = differenceInYears(currentDate, withdrawalStartDate!);
|
||||
}
|
||||
}
|
||||
totalWithdrawn += monthlyWithdrawal;
|
||||
}
|
||||
|
||||
// Update target metrics
|
||||
if (withdrawalsStarted && !targetValue) {
|
||||
targetValue = portfolioValue;
|
||||
yearsToReachTarget = differenceInYears(currentDate, new Date());
|
||||
}
|
||||
|
||||
if (portfolioValue <= 0 && !portfolioDepletionDate) {
|
||||
portfolioDepletionDate = new Date(currentDate);
|
||||
}
|
||||
|
||||
// Only add to projection data if within display timeframe
|
||||
if (currentDate <= endDateForDisplay) {
|
||||
projectionData.push({
|
||||
date: currentDate,
|
||||
value: Math.max(0, portfolioValue),
|
||||
invested: totalInvested,
|
||||
withdrawals: monthlyWithdrawal,
|
||||
totalWithdrawn,
|
||||
});
|
||||
}
|
||||
|
||||
currentDate = addMonths(currentDate, 1);
|
||||
}
|
||||
|
||||
// Calculate actual sustainability duration
|
||||
let actualSustainableYears: number | 'infinite' = 'infinite';
|
||||
if (portfolioDepletionDate) {
|
||||
actualSustainableYears = differenceInYears(
|
||||
portfolioDepletionDate,
|
||||
withdrawalStartDate || new Date()
|
||||
);
|
||||
} else if (portfolioValue > 0) {
|
||||
// If portfolio is still growing after maxProjectionYears, it's truly sustainable
|
||||
actualSustainableYears = 'infinite';
|
||||
}
|
||||
|
||||
return {
|
||||
projection: projectionData,
|
||||
sustainability: {
|
||||
yearsToReachTarget,
|
||||
targetValue,
|
||||
sustainableYears: actualSustainableYears,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,233 +1,233 @@
|
|||
import { addDays, isBefore } from "date-fns";
|
||||
|
||||
import { formatDateToISO } from "../formatters";
|
||||
|
||||
import type { Asset, InvestmentPerformance, PortfolioPerformance } from "../../types";
|
||||
export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerformance => {
|
||||
const investments: InvestmentPerformance[] = [];
|
||||
let totalInvested = 0;
|
||||
let totalCurrentValue = 0;
|
||||
let earliestDate: Date | null = null;
|
||||
|
||||
// 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) {
|
||||
const keys = Array.from(asset.historicalData.values());
|
||||
const firstDay = keys[0];
|
||||
const lastDay = keys[keys.length - 1];
|
||||
firstDayPrices[asset.id] = firstDay;
|
||||
currentPrices[asset.id] = lastDay;
|
||||
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);
|
||||
|
||||
// Calculate portfolio-level annual performances
|
||||
const annualPerformances: { year: number; percentage: number }[] = [];
|
||||
const annualPerformancesPerAsset = new Map<string, { year: number; percentage: number; price: number }[]>();
|
||||
|
||||
// Finde das früheste Investmentdatum
|
||||
for (const asset of assets) {
|
||||
for (const investment of asset.investments) {
|
||||
const investmentDate = new Date(investment.date!);
|
||||
if (!earliestDate || isBefore(investmentDate, earliestDate)) {
|
||||
earliestDate = investmentDate;
|
||||
}
|
||||
}
|
||||
const historicalData = Array.from(asset.historicalData.entries());
|
||||
const firstDate = new Date(historicalData[0][0]);
|
||||
const temp_assetAnnualPerformances: { year: number; percentage: number; price: number }[] = [];
|
||||
for (let year = firstDate.getFullYear(); year <= new Date().getFullYear(); year++) {
|
||||
const yearStart = new Date(year, 0, 1);
|
||||
const yearEnd = new Date(year, 11, 31);
|
||||
let startDate = yearStart;
|
||||
let endDate = yearEnd;
|
||||
let startPrice = asset.historicalData.get(formatDateToISO(startDate));
|
||||
let endPrice = asset.historicalData.get(formatDateToISO(endDate));
|
||||
while(!startPrice || !endPrice) {
|
||||
startDate = addDays(startDate, 1);
|
||||
endDate = addDays(endDate, -1);
|
||||
endPrice = endPrice || asset.historicalData.get(formatDateToISO(endDate)) || 0;
|
||||
startPrice = startPrice || asset.historicalData.get(formatDateToISO(startDate)) || 0;
|
||||
if(endDate.getTime() < yearStart.getTime() || startDate.getTime() > yearEnd.getTime()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startPrice && endPrice) {
|
||||
const percentage = ((endPrice - startPrice) / startPrice) * 100;
|
||||
temp_assetAnnualPerformances.push({
|
||||
year,
|
||||
percentage,
|
||||
price: (endPrice + startPrice) / 2
|
||||
});
|
||||
}
|
||||
}
|
||||
annualPerformancesPerAsset.set(asset.id, temp_assetAnnualPerformances);
|
||||
}
|
||||
|
||||
|
||||
// Calculate portfolio performance for each year
|
||||
const now = new Date();
|
||||
const startYear = earliestDate ? earliestDate.getFullYear() : now.getFullYear();
|
||||
const endYear = now.getFullYear();
|
||||
|
||||
for (let year = startYear; year <= endYear; year++) {
|
||||
const yearStart = new Date(year, 0, 1); // 1. Januar
|
||||
const yearEnd = year === endYear ? new Date(year, now.getMonth(), now.getDate()) : new Date(year, 11, 31); // Aktuelles Datum oder 31. Dez.
|
||||
|
||||
const yearInvestments: { percent: number; weight: number }[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
// Get prices for the start and end of the year
|
||||
let startPrice = 0;
|
||||
let endPrice = 0;
|
||||
let startDate = yearStart;
|
||||
let endDate = yearEnd;
|
||||
while(!startPrice || !endPrice) {
|
||||
startDate = addDays(startDate, 1);
|
||||
endDate = addDays(endDate, -1);
|
||||
endPrice = endPrice || asset.historicalData.get(formatDateToISO(endDate)) || 0;
|
||||
startPrice = startPrice || asset.historicalData.get(formatDateToISO(startDate)) || 0;
|
||||
if(endDate.getTime() < yearStart.getTime() || startDate.getTime() > yearEnd.getTime()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startPrice === 0 || endPrice === 0) {
|
||||
console.warn(`Skipping asset for year ${year} due to missing start or end price`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Get all investments made before or during this year
|
||||
const relevantInvestments = asset.investments.filter(inv =>
|
||||
new Date(inv.date!) <= yearEnd && new Date(inv.date!) >= yearStart
|
||||
);
|
||||
|
||||
for (const investment of relevantInvestments) {
|
||||
const invDate = new Date(investment.date!);
|
||||
|
||||
let buyInPrice = asset.historicalData.get(formatDateToISO(invDate)) || 0;
|
||||
|
||||
// try to find the next closest price prior previousdates
|
||||
if(!buyInPrice) {
|
||||
let previousDate = invDate;
|
||||
let afterDate = invDate;
|
||||
while(!buyInPrice) {
|
||||
previousDate = addDays(previousDate, -1);
|
||||
afterDate = addDays(afterDate, 1);
|
||||
buyInPrice = asset.historicalData.get(formatDateToISO(previousDate)) || asset.historicalData.get(formatDateToISO(afterDate)) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (buyInPrice > 0) {
|
||||
const shares = investment.amount / buyInPrice;
|
||||
const endValue = shares * endPrice;
|
||||
const startValue = shares * startPrice;
|
||||
yearInvestments.push({
|
||||
percent: ((endValue - startValue) / startValue) * 100,
|
||||
weight: startValue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Calculate weighted average performance for the year
|
||||
if (yearInvestments.length > 0) {
|
||||
const totalWeight = yearInvestments.reduce((sum, inv) => sum + inv.weight, 0);
|
||||
const percentage = yearInvestments.reduce((sum, inv) =>
|
||||
sum + (inv.percent * (inv.weight / totalWeight)), 0);
|
||||
|
||||
if (!isNaN(percentage)) {
|
||||
annualPerformances.push({ year, percentage });
|
||||
} else {
|
||||
console.warn(`Invalid percentage calculated for year ${year}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Skipping year ${year} due to zero portfolio values`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get best and worst years for the entire portfolio
|
||||
const bestPerformancePerAnno = annualPerformances.length > 0
|
||||
? Array.from(annualPerformances).sort((a, b) => b.percentage - a.percentage)
|
||||
: [];
|
||||
|
||||
const worstPerformancePerAnno = Array.from(bestPerformancePerAnno).reverse()
|
||||
|
||||
// Normale Performance-Berechnungen...
|
||||
for (const asset of assets) {
|
||||
const historicalVals = Array.from(asset.historicalData.values());
|
||||
const currentPrice = historicalVals[historicalVals.length - 1] || 0;
|
||||
|
||||
for (const investment of asset.investments) {
|
||||
const invDate = new Date(investment.date!);
|
||||
let buyInPrice = asset.historicalData.get(formatDateToISO(invDate)) || 0;
|
||||
if(!buyInPrice) {
|
||||
let previousDate = invDate;
|
||||
let afterDate = invDate;
|
||||
while(!buyInPrice) {
|
||||
previousDate = addDays(previousDate, -1);
|
||||
afterDate = addDays(afterDate, 1);
|
||||
buyInPrice = asset.historicalData.get(formatDateToISO(previousDate)) || asset.historicalData.get(formatDateToISO(afterDate)) || 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,
|
||||
periodicGroupId: investment.periodicGroupId,
|
||||
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;
|
||||
|
||||
|
||||
const performancePerAnnoPerformance = annualPerformances.reduce((acc, curr) => acc + curr.percentage, 0) / annualPerformances.length;
|
||||
|
||||
return {
|
||||
investments,
|
||||
summary: {
|
||||
totalInvested,
|
||||
currentValue: totalCurrentValue,
|
||||
annualPerformancesPerAsset,
|
||||
performancePercentage: totalInvested > 0
|
||||
? ((totalCurrentValue - totalInvested) / totalInvested) * 100
|
||||
: 0,
|
||||
performancePerAnnoPerformance,
|
||||
ttworValue,
|
||||
ttworPercentage,
|
||||
worstPerformancePerAnno: worstPerformancePerAnno,
|
||||
bestPerformancePerAnno: bestPerformancePerAnno,
|
||||
annualPerformances: annualPerformances
|
||||
},
|
||||
};
|
||||
};
|
||||
import { addDays, isBefore } from "date-fns";
|
||||
|
||||
import { formatDateToISO } from "../formatters";
|
||||
|
||||
import type { Asset, InvestmentPerformance, PortfolioPerformance } from "../../types";
|
||||
export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerformance => {
|
||||
const investments: InvestmentPerformance[] = [];
|
||||
let totalInvested = 0;
|
||||
let totalCurrentValue = 0;
|
||||
let earliestDate: Date | null = null;
|
||||
|
||||
// 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) {
|
||||
const keys = Array.from(asset.historicalData.values());
|
||||
const firstDay = keys[0];
|
||||
const lastDay = keys[keys.length - 1];
|
||||
firstDayPrices[asset.id] = firstDay;
|
||||
currentPrices[asset.id] = lastDay;
|
||||
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);
|
||||
|
||||
// Calculate portfolio-level annual performances
|
||||
const annualPerformances: { year: number; percentage: number }[] = [];
|
||||
const annualPerformancesPerAsset = new Map<string, { year: number; percentage: number; price: number }[]>();
|
||||
|
||||
// Finde das früheste Investmentdatum
|
||||
for (const asset of assets) {
|
||||
for (const investment of asset.investments) {
|
||||
const investmentDate = new Date(investment.date!);
|
||||
if (!earliestDate || isBefore(investmentDate, earliestDate)) {
|
||||
earliestDate = investmentDate;
|
||||
}
|
||||
}
|
||||
const historicalData = Array.from(asset.historicalData.entries());
|
||||
const firstDate = new Date(historicalData[0][0]);
|
||||
const temp_assetAnnualPerformances: { year: number; percentage: number; price: number }[] = [];
|
||||
for (let year = firstDate.getFullYear(); year <= new Date().getFullYear(); year++) {
|
||||
const yearStart = new Date(year, 0, 1);
|
||||
const yearEnd = new Date(year, 11, 31);
|
||||
let startDate = yearStart;
|
||||
let endDate = yearEnd;
|
||||
let startPrice = asset.historicalData.get(formatDateToISO(startDate));
|
||||
let endPrice = asset.historicalData.get(formatDateToISO(endDate));
|
||||
while(!startPrice || !endPrice) {
|
||||
startDate = addDays(startDate, 1);
|
||||
endDate = addDays(endDate, -1);
|
||||
endPrice = endPrice || asset.historicalData.get(formatDateToISO(endDate)) || 0;
|
||||
startPrice = startPrice || asset.historicalData.get(formatDateToISO(startDate)) || 0;
|
||||
if(endDate.getTime() < yearStart.getTime() || startDate.getTime() > yearEnd.getTime()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (startPrice && endPrice) {
|
||||
const percentage = ((endPrice - startPrice) / startPrice) * 100;
|
||||
temp_assetAnnualPerformances.push({
|
||||
year,
|
||||
percentage,
|
||||
price: (endPrice + startPrice) / 2
|
||||
});
|
||||
}
|
||||
}
|
||||
annualPerformancesPerAsset.set(asset.id, temp_assetAnnualPerformances);
|
||||
}
|
||||
|
||||
|
||||
// Calculate portfolio performance for each year
|
||||
const now = new Date();
|
||||
const startYear = earliestDate ? earliestDate.getFullYear() : now.getFullYear();
|
||||
const endYear = now.getFullYear();
|
||||
|
||||
for (let year = startYear; year <= endYear; year++) {
|
||||
const yearStart = new Date(year, 0, 1); // 1. Januar
|
||||
const yearEnd = year === endYear ? new Date(year, now.getMonth(), now.getDate()) : new Date(year, 11, 31); // Aktuelles Datum oder 31. Dez.
|
||||
|
||||
const yearInvestments: { percent: number; weight: number }[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
// Get prices for the start and end of the year
|
||||
let startPrice = 0;
|
||||
let endPrice = 0;
|
||||
let startDate = yearStart;
|
||||
let endDate = yearEnd;
|
||||
while(!startPrice || !endPrice) {
|
||||
startDate = addDays(startDate, 1);
|
||||
endDate = addDays(endDate, -1);
|
||||
endPrice = endPrice || asset.historicalData.get(formatDateToISO(endDate)) || 0;
|
||||
startPrice = startPrice || asset.historicalData.get(formatDateToISO(startDate)) || 0;
|
||||
if(endDate.getTime() < yearStart.getTime() || startDate.getTime() > yearEnd.getTime()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startPrice === 0 || endPrice === 0) {
|
||||
console.warn(`Skipping asset for year ${year} due to missing start or end price`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Get all investments made before or during this year
|
||||
const relevantInvestments = asset.investments.filter(inv =>
|
||||
new Date(inv.date!) <= yearEnd && new Date(inv.date!) >= yearStart
|
||||
);
|
||||
|
||||
for (const investment of relevantInvestments) {
|
||||
const invDate = new Date(investment.date!);
|
||||
|
||||
let buyInPrice = asset.historicalData.get(formatDateToISO(invDate)) || 0;
|
||||
|
||||
// try to find the next closest price prior previousdates
|
||||
if(!buyInPrice) {
|
||||
let previousDate = invDate;
|
||||
let afterDate = invDate;
|
||||
while(!buyInPrice) {
|
||||
previousDate = addDays(previousDate, -1);
|
||||
afterDate = addDays(afterDate, 1);
|
||||
buyInPrice = asset.historicalData.get(formatDateToISO(previousDate)) || asset.historicalData.get(formatDateToISO(afterDate)) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (buyInPrice > 0) {
|
||||
const shares = investment.amount / buyInPrice;
|
||||
const endValue = shares * endPrice;
|
||||
const startValue = shares * startPrice;
|
||||
yearInvestments.push({
|
||||
percent: ((endValue - startValue) / startValue) * 100,
|
||||
weight: startValue
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Calculate weighted average performance for the year
|
||||
if (yearInvestments.length > 0) {
|
||||
const totalWeight = yearInvestments.reduce((sum, inv) => sum + inv.weight, 0);
|
||||
const percentage = yearInvestments.reduce((sum, inv) =>
|
||||
sum + (inv.percent * (inv.weight / totalWeight)), 0);
|
||||
|
||||
if (!isNaN(percentage)) {
|
||||
annualPerformances.push({ year, percentage });
|
||||
} else {
|
||||
console.warn(`Invalid percentage calculated for year ${year}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`Skipping year ${year} due to zero portfolio values`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get best and worst years for the entire portfolio
|
||||
const bestPerformancePerAnno = annualPerformances.length > 0
|
||||
? Array.from(annualPerformances).sort((a, b) => b.percentage - a.percentage)
|
||||
: [];
|
||||
|
||||
const worstPerformancePerAnno = Array.from(bestPerformancePerAnno).reverse()
|
||||
|
||||
// Normale Performance-Berechnungen...
|
||||
for (const asset of assets) {
|
||||
const historicalVals = Array.from(asset.historicalData.values());
|
||||
const currentPrice = historicalVals[historicalVals.length - 1] || 0;
|
||||
|
||||
for (const investment of asset.investments) {
|
||||
const invDate = new Date(investment.date!);
|
||||
let buyInPrice = asset.historicalData.get(formatDateToISO(invDate)) || 0;
|
||||
if(!buyInPrice) {
|
||||
let previousDate = invDate;
|
||||
let afterDate = invDate;
|
||||
while(!buyInPrice) {
|
||||
previousDate = addDays(previousDate, -1);
|
||||
afterDate = addDays(afterDate, 1);
|
||||
buyInPrice = asset.historicalData.get(formatDateToISO(previousDate)) || asset.historicalData.get(formatDateToISO(afterDate)) || 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,
|
||||
periodicGroupId: investment.periodicGroupId,
|
||||
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;
|
||||
|
||||
|
||||
const performancePerAnnoPerformance = annualPerformances.reduce((acc, curr) => acc + curr.percentage, 0) / annualPerformances.length;
|
||||
|
||||
return {
|
||||
investments,
|
||||
summary: {
|
||||
totalInvested,
|
||||
currentValue: totalCurrentValue,
|
||||
annualPerformancesPerAsset,
|
||||
performancePercentage: totalInvested > 0
|
||||
? ((totalCurrentValue - totalInvested) / totalInvested) * 100
|
||||
: 0,
|
||||
performancePerAnnoPerformance,
|
||||
ttworValue,
|
||||
ttworPercentage,
|
||||
worstPerformancePerAnno: worstPerformancePerAnno,
|
||||
bestPerformancePerAnno: bestPerformancePerAnno,
|
||||
annualPerformances: annualPerformances
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,96 +1,96 @@
|
|||
import { addDays, isAfter, isBefore, isSameDay } from "date-fns";
|
||||
|
||||
import { formatDateToISO } from "../formatters";
|
||||
import { calculateAssetValueAtDate } from "./assetValue";
|
||||
|
||||
import type { Asset, DateRange, DayData } from "../../types";
|
||||
interface WeightedPercent {
|
||||
percent: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
|
||||
export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => {
|
||||
const { startDate, endDate } = dateRange;
|
||||
const data: DayData[] = [];
|
||||
|
||||
let currentDate = startDate;
|
||||
const end = endDate;
|
||||
|
||||
const beforeValue: { [assetId: string]: number } = {};
|
||||
|
||||
while (isBefore(currentDate, end)) {
|
||||
const dayData: DayData = {
|
||||
date: currentDate,
|
||||
total: 0,
|
||||
invested: 0,
|
||||
percentageChange: 0,
|
||||
assets: {},
|
||||
};
|
||||
|
||||
const weightedPercents: WeightedPercent[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
// calculate the invested kapital
|
||||
for (const investment of asset.investments) {
|
||||
const invDate = new Date(investment.date!);
|
||||
if (!isAfter(invDate, currentDate) && !isSameDay(invDate, currentDate)) dayData.invested += investment.amount;
|
||||
}
|
||||
|
||||
const currentValueOfAsset = asset.historicalData.get(formatDateToISO(dayData.date)) || 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;
|
||||
|
||||
let performancePercentage = 0;
|
||||
if (investedValue > 0) {
|
||||
performancePercentage = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
|
||||
}
|
||||
|
||||
weightedPercents.push({
|
||||
percent: performancePercentage,
|
||||
weight: investedValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate weighted average percentage change
|
||||
if (weightedPercents.length > 0) {
|
||||
const totalWeight = weightedPercents.reduce((sum, wp) => sum + wp.weight, 0);
|
||||
dayData.percentageChange = weightedPercents.reduce((sum, wp) =>
|
||||
sum + (wp.percent * (wp.weight / totalWeight)), 0);
|
||||
} else {
|
||||
dayData.percentageChange = 0;
|
||||
}
|
||||
|
||||
const totalInvested = dayData.invested; // Total invested amount for the day
|
||||
const totalCurrentValue = dayData.total; // Total current value for the day
|
||||
|
||||
if (totalInvested > 0 && totalCurrentValue > 0) {
|
||||
dayData.percentageChange = ((totalCurrentValue - totalInvested) / totalInvested) * 100;
|
||||
} else {
|
||||
dayData.percentageChange = 0;
|
||||
}
|
||||
|
||||
|
||||
currentDate = addDays(currentDate, 1);
|
||||
data.push(dayData);
|
||||
}
|
||||
|
||||
// Filter out days with incomplete asset data
|
||||
return data.filter(
|
||||
(dayData) => {
|
||||
const vals = Object.values(dayData.assets);
|
||||
if (!vals.length) return false;
|
||||
return !vals.some((value) => value === 0);
|
||||
}
|
||||
);
|
||||
};
|
||||
import { addDays, isAfter, isBefore, isSameDay } from "date-fns";
|
||||
|
||||
import { formatDateToISO } from "../formatters";
|
||||
import { calculateAssetValueAtDate } from "./assetValue";
|
||||
|
||||
import type { Asset, DateRange, DayData } from "../../types";
|
||||
interface WeightedPercent {
|
||||
percent: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
|
||||
export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => {
|
||||
const { startDate, endDate } = dateRange;
|
||||
const data: DayData[] = [];
|
||||
|
||||
let currentDate = startDate;
|
||||
const end = endDate;
|
||||
|
||||
const beforeValue: { [assetId: string]: number } = {};
|
||||
|
||||
while (isBefore(currentDate, end)) {
|
||||
const dayData: DayData = {
|
||||
date: currentDate,
|
||||
total: 0,
|
||||
invested: 0,
|
||||
percentageChange: 0,
|
||||
assets: {},
|
||||
};
|
||||
|
||||
const weightedPercents: WeightedPercent[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
// calculate the invested kapital
|
||||
for (const investment of asset.investments) {
|
||||
const invDate = new Date(investment.date!);
|
||||
if (!isAfter(invDate, currentDate) && !isSameDay(invDate, currentDate)) dayData.invested += investment.amount;
|
||||
}
|
||||
|
||||
const currentValueOfAsset = asset.historicalData.get(formatDateToISO(dayData.date)) || 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;
|
||||
|
||||
let performancePercentage = 0;
|
||||
if (investedValue > 0) {
|
||||
performancePercentage = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
|
||||
}
|
||||
|
||||
weightedPercents.push({
|
||||
percent: performancePercentage,
|
||||
weight: investedValue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate weighted average percentage change
|
||||
if (weightedPercents.length > 0) {
|
||||
const totalWeight = weightedPercents.reduce((sum, wp) => sum + wp.weight, 0);
|
||||
dayData.percentageChange = weightedPercents.reduce((sum, wp) =>
|
||||
sum + (wp.percent * (wp.weight / totalWeight)), 0);
|
||||
} else {
|
||||
dayData.percentageChange = 0;
|
||||
}
|
||||
|
||||
const totalInvested = dayData.invested; // Total invested amount for the day
|
||||
const totalCurrentValue = dayData.total; // Total current value for the day
|
||||
|
||||
if (totalInvested > 0 && totalCurrentValue > 0) {
|
||||
dayData.percentageChange = ((totalCurrentValue - totalInvested) / totalInvested) * 100;
|
||||
} else {
|
||||
dayData.percentageChange = 0;
|
||||
}
|
||||
|
||||
|
||||
currentDate = addDays(currentDate, 1);
|
||||
data.push(dayData);
|
||||
}
|
||||
|
||||
// Filter out days with incomplete asset data
|
||||
return data.filter(
|
||||
(dayData) => {
|
||||
const vals = Object.values(dayData.assets);
|
||||
// Keep days where at least one asset has data
|
||||
return vals.length > 0 && vals.some(value => value > 0);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,339 +1,339 @@
|
|||
import "jspdf-autotable";
|
||||
|
||||
import { isBefore, isSameDay } from "date-fns";
|
||||
import { jsPDF } from "jspdf";
|
||||
|
||||
import { Asset, PortfolioPerformance } from "../types";
|
||||
import { calculateFutureProjection } from "./calculations/futureProjection";
|
||||
|
||||
// Add type augmentation for the autotable plugin
|
||||
interface jsPDFWithPlugin extends jsPDF {
|
||||
autoTable: (options: any) => void;
|
||||
}
|
||||
|
||||
const formatEuro = (value: number) => {
|
||||
return `€${value.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ".").replace(".", ",").replace(/,(\d{3})/g, ".$1")}`;
|
||||
};
|
||||
|
||||
export const downloadTableAsCSV = (tableData: any[], filename: string) => {
|
||||
const headers = Object.keys(tableData[0])
|
||||
.filter(header => !header.toLowerCase().includes('id'));
|
||||
|
||||
const csvContent = [
|
||||
headers.map(title => title.charAt(0).toUpperCase() + title.slice(1)).join(','),
|
||||
...tableData.map(row =>
|
||||
headers.map(header => {
|
||||
const value = row[header]?.toString().replace(/,/g, '');
|
||||
return isNaN(Number(value))
|
||||
? `"${value}"`
|
||||
: formatEuro(Number(value)).replace('€', '');
|
||||
}).join(',')
|
||||
)
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `${filename}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
export const generatePortfolioPDF = async (
|
||||
assets: Asset[],
|
||||
performance: PortfolioPerformance,
|
||||
savingsPlansPerformance: any[],
|
||||
performancePerAnno: number
|
||||
) => {
|
||||
const doc = new jsPDF() as jsPDFWithPlugin;
|
||||
doc.setFont('Arial', 'normal');
|
||||
let yPos = 20;
|
||||
|
||||
// Title
|
||||
doc.setFontSize(20);
|
||||
doc.text('Portfolio Analysis Report', 15, yPos);
|
||||
yPos += 15;
|
||||
|
||||
// Explanations
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(100);
|
||||
|
||||
// TTWOR Explanation
|
||||
doc.text('Understanding TTWOR (Time Travel Without Risk):', 15, yPos);
|
||||
yPos += 7;
|
||||
const ttworText =
|
||||
'TTWOR shows how your portfolio would have performed if all investments had been made at ' +
|
||||
'the beginning of the period. This metric helps evaluate the impact of your investment ' +
|
||||
'timing strategy compared to a single early investment. A higher portfolio performance ' +
|
||||
'than TTWOR indicates successful timing of investments.';
|
||||
|
||||
const ttworLines = doc.splitTextToSize(ttworText, 180);
|
||||
doc.text(ttworLines, 20, yPos);
|
||||
yPos += ttworLines.length * 7;
|
||||
|
||||
doc.setTextColor(0);
|
||||
|
||||
// Portfolio Summary
|
||||
doc.setFontSize(16);
|
||||
doc.text('Portfolio Summary', 15, yPos);
|
||||
yPos += 10;
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.text(`Total Invested: ${formatEuro(performance.summary.totalInvested)}`, 20, yPos);
|
||||
yPos += 7;
|
||||
doc.text(`Current Value: ${formatEuro(performance.summary.currentValue)}`, 20, yPos);
|
||||
yPos += 7;
|
||||
doc.text(`Performance: ${performance.summary.performancePercentage.toFixed(2)}% (p.a. ${performance.summary.performancePerAnnoPerformance.toFixed(2)}%)`, 20, yPos);
|
||||
yPos += 7;
|
||||
|
||||
// TTWOR values in italic
|
||||
doc.setFont('Arial', 'italic');
|
||||
doc.text(`TTWOR* Value: ${formatEuro(performance.summary.ttworValue)} (would perform: ${performance.summary.ttworPercentage.toFixed(2)}%)`, 20, yPos);
|
||||
doc.setFont('Arial', 'normal');
|
||||
yPos += 15;
|
||||
|
||||
// Add Positions Overview table
|
||||
doc.setFontSize(16);
|
||||
doc.text('Positions Overview', 15, yPos);
|
||||
yPos += 10;
|
||||
|
||||
// Prepare positions data
|
||||
const positionsTableData = [
|
||||
// Summary row
|
||||
[
|
||||
'Total Portfolio',
|
||||
'',
|
||||
'',
|
||||
formatEuro(performance.summary.totalInvested),
|
||||
formatEuro(performance.summary.currentValue),
|
||||
'',
|
||||
`${performance.summary.performancePercentage.toFixed(2)}% (p.a. ${performance.summary.performancePerAnnoPerformance.toFixed(2)}%)`,
|
||||
],
|
||||
// TTWOR row
|
||||
[
|
||||
'TTWOR*',
|
||||
'',
|
||||
performance.investments[0]?.date
|
||||
? new Date(performance.investments[0].date).toLocaleDateString('de-DE')
|
||||
: '',
|
||||
formatEuro(performance.summary.totalInvested),
|
||||
formatEuro(performance.summary.ttworValue),
|
||||
'',
|
||||
`${performance.summary.ttworPercentage.toFixed(2)}%`,
|
||||
],
|
||||
// Individual positions
|
||||
...performance.investments.sort((a, b) => (isBefore(a.date, b.date) || isSameDay(a.date, b.date)) ? -1 : 1).map((inv) => {
|
||||
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: any) => v.assetName === inv.assetName);
|
||||
const avgBuyIn = filtered.reduce((acc: any, curr: any) => acc + curr.investedAtPrice, 0) / filtered.length;
|
||||
|
||||
return [
|
||||
inv.assetName,
|
||||
investment.type === 'periodic' ? 'SavingsPlan' : 'OneTime',
|
||||
new Date(inv.date).toLocaleDateString('de-DE'),
|
||||
formatEuro(inv.investedAmount),
|
||||
formatEuro(inv.currentValue),
|
||||
`${formatEuro(inv.investedAtPrice)} (${formatEuro(avgBuyIn)})`,
|
||||
`${inv.performancePercentage.toFixed(2)}%`,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
doc.autoTable({
|
||||
startY: yPos,
|
||||
head: [['Asset', 'Type', 'Date', 'Invested Amount', 'Current Value', 'Buy-In (avg)', 'Performance']],
|
||||
body: positionsTableData,
|
||||
styles: {
|
||||
cellPadding: 2,
|
||||
fontSize: 8,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [240, 240, 240],
|
||||
textColor: [0, 0, 0],
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
// Style for summary and TTWOR rows
|
||||
rowStyles: (row:number) => {
|
||||
if (row === 0) return { fontStyle: 'bold', fillColor: [245, 245, 245] };
|
||||
if (row === 1) return { fontStyle: 'italic', textColor: [100, 100, 100] };
|
||||
return {};
|
||||
},
|
||||
});
|
||||
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||
|
||||
// Savings Plans Table if exists
|
||||
if (savingsPlansPerformance.length > 0) {
|
||||
doc.setFontSize(16);
|
||||
doc.text('Savings Plans Performance', 15, yPos);
|
||||
yPos += 10;
|
||||
|
||||
const savingsPlansTableData = savingsPlansPerformance.map(plan => [
|
||||
plan.assetName,
|
||||
formatEuro(plan.amount),
|
||||
formatEuro(plan.totalInvested),
|
||||
formatEuro(plan.currentValue),
|
||||
`${plan.performancePercentage.toFixed(2)}%`,
|
||||
`${plan.performancePerAnnoPerformance.toFixed(2)}%`
|
||||
]);
|
||||
|
||||
doc.autoTable({
|
||||
startY: yPos,
|
||||
head: [['Asset', 'Interval Amount', 'Total Invested', 'Current Value', 'Performance', 'Performance (p.a.)']],
|
||||
body: savingsPlansTableData,
|
||||
});
|
||||
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||
}
|
||||
|
||||
// Add page break before future projections
|
||||
doc.addPage();
|
||||
yPos = 20;
|
||||
|
||||
// Future Projections
|
||||
doc.setFontSize(16);
|
||||
doc.text('Future Projections', 15, yPos);
|
||||
yPos += 15;
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(100);
|
||||
// Future Projections Explanation
|
||||
doc.text('About Future Projections:', 15, yPos);
|
||||
yPos += 7;
|
||||
const projectionText =
|
||||
'The future projections are calculated using your portfolio\'s historical performance ' +
|
||||
`(${performancePerAnno.toFixed(2)}% p.a.) as a baseline. The chart shows different time horizons ` +
|
||||
'to help visualize potential growth scenarios. These projections are estimates based on ' +
|
||||
'historical data and should not be considered guaranteed returns.';
|
||||
|
||||
doc.setTextColor(0);
|
||||
const projectionLines = doc.splitTextToSize(projectionText, 180);
|
||||
doc.text(projectionLines, 20, yPos);
|
||||
yPos += projectionLines.length * 7 - 7;
|
||||
|
||||
|
||||
const years = [10, 15, 20, 30, 40];
|
||||
const chartWidth = 180;
|
||||
const chartHeight = 100;
|
||||
|
||||
// Calculate all projections first
|
||||
const allProjections = await Promise.all(years.map(async year => {
|
||||
const { projection } = await calculateFutureProjection(assets, year, performancePerAnno, {
|
||||
enabled: false,
|
||||
amount: 0,
|
||||
interval: 'monthly',
|
||||
startTrigger: 'date'
|
||||
});
|
||||
return { year, projection };
|
||||
}));
|
||||
|
||||
// Show summary table
|
||||
const projectionSummary = allProjections.map(({ year, projection }) => {
|
||||
const projected = projection[projection.length - 1];
|
||||
return [
|
||||
`${year} Years`,
|
||||
formatEuro(projected.invested),
|
||||
formatEuro(projected.value),
|
||||
`${((projected.value - projected.invested) / projected.invested * 100).toFixed(2)}%`
|
||||
];
|
||||
});
|
||||
|
||||
doc.autoTable({
|
||||
startY: yPos,
|
||||
head: [['Timeframe', 'Invested Amount', 'Expected Value', '% Gain']],
|
||||
body: projectionSummary,
|
||||
});
|
||||
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||
|
||||
// Draw combined chart
|
||||
const maxValue = Math.max(...allProjections.flatMap(p => p.projection.map(d => d.value)));
|
||||
const yAxisSteps = 5;
|
||||
const stepSize = maxValue / yAxisSteps;
|
||||
const legendHeight = 40; // Height for legend section
|
||||
|
||||
// Draw axes
|
||||
doc.setDrawColor(200);
|
||||
doc.line(15, yPos, 15, yPos + chartHeight); // Y axis
|
||||
doc.line(15, yPos + chartHeight, 15 + chartWidth, yPos + chartHeight); // X axis
|
||||
|
||||
// Draw Y-axis labels and grid lines
|
||||
doc.setFontSize(8);
|
||||
doc.setDrawColor(230);
|
||||
for (let i = 0; i <= yAxisSteps; i++) {
|
||||
const value = maxValue - (i * stepSize);
|
||||
const y = yPos + (i * (chartHeight / yAxisSteps));
|
||||
doc.text(formatEuro(value), 5, y + 3);
|
||||
doc.line(15, y, 15 + chartWidth, y); // Grid line
|
||||
}
|
||||
|
||||
const colors: [number, number, number][] = [
|
||||
[0, 100, 255], // Blue
|
||||
[255, 100, 0], // Orange
|
||||
[0, 200, 100], // Green
|
||||
[200, 0, 200], // Purple
|
||||
[255, 0, 0], // Red
|
||||
];
|
||||
|
||||
// Draw lines for each projection
|
||||
allProjections.forEach(({ projection }, index) => {
|
||||
const points = projection.map((p, i) => [
|
||||
15 + (i * (chartWidth / projection.length)),
|
||||
yPos + chartHeight - (p.value / maxValue * chartHeight)
|
||||
]);
|
||||
|
||||
doc.setDrawColor(...(colors[index]));
|
||||
doc.setLineWidth(0.5);
|
||||
points.forEach((point, i) => {
|
||||
if (i > 0) {
|
||||
doc.line(points[i - 1][0], points[i - 1][1], point[0], point[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add date labels
|
||||
doc.setFontSize(8);
|
||||
doc.setDrawColor(0);
|
||||
|
||||
// Draw legend at bottom
|
||||
const legendY = yPos + chartHeight + 20;
|
||||
const legendItemWidth = chartWidth / years.length;
|
||||
|
||||
doc.setFontSize(8);
|
||||
allProjections.forEach(({ year }, index) => {
|
||||
const x = 15 + (index * legendItemWidth);
|
||||
|
||||
// Draw color line
|
||||
doc.setDrawColor(...colors[index]);
|
||||
doc.setLineWidth(1);
|
||||
doc.line(x, legendY + 4, x + 15, legendY + 4);
|
||||
|
||||
// Draw text
|
||||
doc.setTextColor(0);
|
||||
doc.text(`${year} Years`, x + 20, legendY + 6);
|
||||
});
|
||||
|
||||
yPos += chartHeight + legendHeight; // Update yPos to include legend space
|
||||
|
||||
// Add footer with link
|
||||
const footerText = 'Built by Tomato6966 - SourceCode';
|
||||
const link = 'https://github.com/Tomato6966/investment-portfolio-simulator';
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(100);
|
||||
const pageHeight = doc.internal.pageSize.height;
|
||||
|
||||
// Add to all pages
|
||||
// @ts-expect-error - doc.internal.getNumberOfPages() is not typed
|
||||
const totalPages = doc.internal.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
|
||||
// Footer text with link
|
||||
doc.text(footerText, 15, pageHeight - 10);
|
||||
|
||||
// Add link annotation
|
||||
doc.link(15, pageHeight - 15, doc.getTextWidth(footerText), 10, { url: link });
|
||||
|
||||
// Page numbers
|
||||
doc.text(`Page ${i} of ${totalPages}`, doc.internal.pageSize.width - 30, pageHeight - 10);
|
||||
}
|
||||
|
||||
doc.save('portfolio-analysis.pdf');
|
||||
};
|
||||
import "jspdf-autotable";
|
||||
|
||||
import { isBefore, isSameDay } from "date-fns";
|
||||
import { jsPDF } from "jspdf";
|
||||
|
||||
import { Asset, PortfolioPerformance } from "../types";
|
||||
import { calculateFutureProjection } from "./calculations/futureProjection";
|
||||
|
||||
// Add type augmentation for the autotable plugin
|
||||
interface jsPDFWithPlugin extends jsPDF {
|
||||
autoTable: (options: any) => void;
|
||||
}
|
||||
|
||||
const formatEuro = (value: number) => {
|
||||
return `€${value.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ".").replace(".", ",").replace(/,(\d{3})/g, ".$1")}`;
|
||||
};
|
||||
|
||||
export const downloadTableAsCSV = (tableData: any[], filename: string) => {
|
||||
const headers = Object.keys(tableData[0])
|
||||
.filter(header => !header.toLowerCase().includes('id'));
|
||||
|
||||
const csvContent = [
|
||||
headers.map(title => title.charAt(0).toUpperCase() + title.slice(1)).join(','),
|
||||
...tableData.map(row =>
|
||||
headers.map(header => {
|
||||
const value = row[header]?.toString().replace(/,/g, '');
|
||||
return isNaN(Number(value))
|
||||
? `"${value}"`
|
||||
: formatEuro(Number(value)).replace('€', '');
|
||||
}).join(',')
|
||||
)
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `${filename}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
export const generatePortfolioPDF = async (
|
||||
assets: Asset[],
|
||||
performance: PortfolioPerformance,
|
||||
savingsPlansPerformance: any[],
|
||||
performancePerAnno: number
|
||||
) => {
|
||||
const doc = new jsPDF() as jsPDFWithPlugin;
|
||||
doc.setFont('Arial', 'normal');
|
||||
let yPos = 20;
|
||||
|
||||
// Title
|
||||
doc.setFontSize(20);
|
||||
doc.text('Portfolio Analysis Report', 15, yPos);
|
||||
yPos += 15;
|
||||
|
||||
// Explanations
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(100);
|
||||
|
||||
// TTWOR Explanation
|
||||
doc.text('Understanding TTWOR (Time Travel Without Risk):', 15, yPos);
|
||||
yPos += 7;
|
||||
const ttworText =
|
||||
'TTWOR shows how your portfolio would have performed if all investments had been made at ' +
|
||||
'the beginning of the period. This metric helps evaluate the impact of your investment ' +
|
||||
'timing strategy compared to a single early investment. A higher portfolio performance ' +
|
||||
'than TTWOR indicates successful timing of investments.';
|
||||
|
||||
const ttworLines = doc.splitTextToSize(ttworText, 180);
|
||||
doc.text(ttworLines, 20, yPos);
|
||||
yPos += ttworLines.length * 7;
|
||||
|
||||
doc.setTextColor(0);
|
||||
|
||||
// Portfolio Summary
|
||||
doc.setFontSize(16);
|
||||
doc.text('Portfolio Summary', 15, yPos);
|
||||
yPos += 10;
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.text(`Total Invested: ${formatEuro(performance.summary.totalInvested)}`, 20, yPos);
|
||||
yPos += 7;
|
||||
doc.text(`Current Value: ${formatEuro(performance.summary.currentValue)}`, 20, yPos);
|
||||
yPos += 7;
|
||||
doc.text(`Performance: ${performance.summary.performancePercentage.toFixed(2)}% (p.a. ${performance.summary.performancePerAnnoPerformance.toFixed(2)}%)`, 20, yPos);
|
||||
yPos += 7;
|
||||
|
||||
// TTWOR values in italic
|
||||
doc.setFont('Arial', 'italic');
|
||||
doc.text(`TTWOR* Value: ${formatEuro(performance.summary.ttworValue)} (would perform: ${performance.summary.ttworPercentage.toFixed(2)}%)`, 20, yPos);
|
||||
doc.setFont('Arial', 'normal');
|
||||
yPos += 15;
|
||||
|
||||
// Add Positions Overview table
|
||||
doc.setFontSize(16);
|
||||
doc.text('Positions Overview', 15, yPos);
|
||||
yPos += 10;
|
||||
|
||||
// Prepare positions data
|
||||
const positionsTableData = [
|
||||
// Summary row
|
||||
[
|
||||
'Total Portfolio',
|
||||
'',
|
||||
'',
|
||||
formatEuro(performance.summary.totalInvested),
|
||||
formatEuro(performance.summary.currentValue),
|
||||
'',
|
||||
`${performance.summary.performancePercentage.toFixed(2)}% (p.a. ${performance.summary.performancePerAnnoPerformance.toFixed(2)}%)`,
|
||||
],
|
||||
// TTWOR row
|
||||
[
|
||||
'TTWOR*',
|
||||
'',
|
||||
performance.investments[0]?.date
|
||||
? new Date(performance.investments[0].date).toLocaleDateString('de-DE')
|
||||
: '',
|
||||
formatEuro(performance.summary.totalInvested),
|
||||
formatEuro(performance.summary.ttworValue),
|
||||
'',
|
||||
`${performance.summary.ttworPercentage.toFixed(2)}%`,
|
||||
],
|
||||
// Individual positions
|
||||
...performance.investments.sort((a, b) => (isBefore(a.date, b.date) || isSameDay(a.date, b.date)) ? -1 : 1).map((inv) => {
|
||||
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: any) => v.assetName === inv.assetName);
|
||||
const avgBuyIn = filtered.reduce((acc: any, curr: any) => acc + curr.investedAtPrice, 0) / filtered.length;
|
||||
|
||||
return [
|
||||
inv.assetName,
|
||||
investment.type === 'periodic' ? 'SavingsPlan' : 'OneTime',
|
||||
new Date(inv.date).toLocaleDateString('de-DE'),
|
||||
formatEuro(inv.investedAmount),
|
||||
formatEuro(inv.currentValue),
|
||||
`${formatEuro(inv.investedAtPrice)} (${formatEuro(avgBuyIn)})`,
|
||||
`${inv.performancePercentage.toFixed(2)}%`,
|
||||
];
|
||||
}),
|
||||
];
|
||||
|
||||
doc.autoTable({
|
||||
startY: yPos,
|
||||
head: [['Asset', 'Type', 'Date', 'Invested Amount', 'Current Value', 'Buy-In (avg)', 'Performance']],
|
||||
body: positionsTableData,
|
||||
styles: {
|
||||
cellPadding: 2,
|
||||
fontSize: 8,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [240, 240, 240],
|
||||
textColor: [0, 0, 0],
|
||||
fontStyle: 'bold',
|
||||
},
|
||||
// Style for summary and TTWOR rows
|
||||
rowStyles: (row:number) => {
|
||||
if (row === 0) return { fontStyle: 'bold', fillColor: [245, 245, 245] };
|
||||
if (row === 1) return { fontStyle: 'italic', textColor: [100, 100, 100] };
|
||||
return {};
|
||||
},
|
||||
});
|
||||
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||
|
||||
// Savings Plans Table if exists
|
||||
if (savingsPlansPerformance.length > 0) {
|
||||
doc.setFontSize(16);
|
||||
doc.text('Savings Plans Performance', 15, yPos);
|
||||
yPos += 10;
|
||||
|
||||
const savingsPlansTableData = savingsPlansPerformance.map(plan => [
|
||||
plan.assetName,
|
||||
formatEuro(plan.amount),
|
||||
formatEuro(plan.totalInvested),
|
||||
formatEuro(plan.currentValue),
|
||||
`${plan.performancePercentage.toFixed(2)}%`,
|
||||
`${plan.performancePerAnnoPerformance.toFixed(2)}%`
|
||||
]);
|
||||
|
||||
doc.autoTable({
|
||||
startY: yPos,
|
||||
head: [['Asset', 'Interval Amount', 'Total Invested', 'Current Value', 'Performance', 'Performance (p.a.)']],
|
||||
body: savingsPlansTableData,
|
||||
});
|
||||
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||
}
|
||||
|
||||
// Add page break before future projections
|
||||
doc.addPage();
|
||||
yPos = 20;
|
||||
|
||||
// Future Projections
|
||||
doc.setFontSize(16);
|
||||
doc.text('Future Projections', 15, yPos);
|
||||
yPos += 15;
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(100);
|
||||
// Future Projections Explanation
|
||||
doc.text('About Future Projections:', 15, yPos);
|
||||
yPos += 7;
|
||||
const projectionText =
|
||||
'The future projections are calculated using your portfolio\'s historical performance ' +
|
||||
`(${performancePerAnno.toFixed(2)}% p.a.) as a baseline. The chart shows different time horizons ` +
|
||||
'to help visualize potential growth scenarios. These projections are estimates based on ' +
|
||||
'historical data and should not be considered guaranteed returns.';
|
||||
|
||||
doc.setTextColor(0);
|
||||
const projectionLines = doc.splitTextToSize(projectionText, 180);
|
||||
doc.text(projectionLines, 20, yPos);
|
||||
yPos += projectionLines.length * 7 - 7;
|
||||
|
||||
|
||||
const years = [10, 15, 20, 30, 40];
|
||||
const chartWidth = 180;
|
||||
const chartHeight = 100;
|
||||
|
||||
// Calculate all projections first
|
||||
const allProjections = await Promise.all(years.map(async year => {
|
||||
const { projection } = await calculateFutureProjection(assets, year, performancePerAnno, {
|
||||
enabled: false,
|
||||
amount: 0,
|
||||
interval: 'monthly',
|
||||
startTrigger: 'date'
|
||||
});
|
||||
return { year, projection };
|
||||
}));
|
||||
|
||||
// Show summary table
|
||||
const projectionSummary = allProjections.map(({ year, projection }) => {
|
||||
const projected = projection[projection.length - 1];
|
||||
return [
|
||||
`${year} Years`,
|
||||
formatEuro(projected.invested),
|
||||
formatEuro(projected.value),
|
||||
`${((projected.value - projected.invested) / projected.invested * 100).toFixed(2)}%`
|
||||
];
|
||||
});
|
||||
|
||||
doc.autoTable({
|
||||
startY: yPos,
|
||||
head: [['Timeframe', 'Invested Amount', 'Expected Value', '% Gain']],
|
||||
body: projectionSummary,
|
||||
});
|
||||
yPos = (doc as any).lastAutoTable.finalY + 15;
|
||||
|
||||
// Draw combined chart
|
||||
const maxValue = Math.max(...allProjections.flatMap(p => p.projection.map(d => d.value)));
|
||||
const yAxisSteps = 5;
|
||||
const stepSize = maxValue / yAxisSteps;
|
||||
const legendHeight = 40; // Height for legend section
|
||||
|
||||
// Draw axes
|
||||
doc.setDrawColor(200);
|
||||
doc.line(15, yPos, 15, yPos + chartHeight); // Y axis
|
||||
doc.line(15, yPos + chartHeight, 15 + chartWidth, yPos + chartHeight); // X axis
|
||||
|
||||
// Draw Y-axis labels and grid lines
|
||||
doc.setFontSize(8);
|
||||
doc.setDrawColor(230);
|
||||
for (let i = 0; i <= yAxisSteps; i++) {
|
||||
const value = maxValue - (i * stepSize);
|
||||
const y = yPos + (i * (chartHeight / yAxisSteps));
|
||||
doc.text(formatEuro(value), 5, y + 3);
|
||||
doc.line(15, y, 15 + chartWidth, y); // Grid line
|
||||
}
|
||||
|
||||
const colors: [number, number, number][] = [
|
||||
[0, 100, 255], // Blue
|
||||
[255, 100, 0], // Orange
|
||||
[0, 200, 100], // Green
|
||||
[200, 0, 200], // Purple
|
||||
[255, 0, 0], // Red
|
||||
];
|
||||
|
||||
// Draw lines for each projection
|
||||
allProjections.forEach(({ projection }, index) => {
|
||||
const points = projection.map((p, i) => [
|
||||
15 + (i * (chartWidth / projection.length)),
|
||||
yPos + chartHeight - (p.value / maxValue * chartHeight)
|
||||
]);
|
||||
|
||||
doc.setDrawColor(...(colors[index]));
|
||||
doc.setLineWidth(0.5);
|
||||
points.forEach((point, i) => {
|
||||
if (i > 0) {
|
||||
doc.line(points[i - 1][0], points[i - 1][1], point[0], point[1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add date labels
|
||||
doc.setFontSize(8);
|
||||
doc.setDrawColor(0);
|
||||
|
||||
// Draw legend at bottom
|
||||
const legendY = yPos + chartHeight + 20;
|
||||
const legendItemWidth = chartWidth / years.length;
|
||||
|
||||
doc.setFontSize(8);
|
||||
allProjections.forEach(({ year }, index) => {
|
||||
const x = 15 + (index * legendItemWidth);
|
||||
|
||||
// Draw color line
|
||||
doc.setDrawColor(...colors[index]);
|
||||
doc.setLineWidth(1);
|
||||
doc.line(x, legendY + 4, x + 15, legendY + 4);
|
||||
|
||||
// Draw text
|
||||
doc.setTextColor(0);
|
||||
doc.text(`${year} Years`, x + 20, legendY + 6);
|
||||
});
|
||||
|
||||
yPos += chartHeight + legendHeight; // Update yPos to include legend space
|
||||
|
||||
// Add footer with link
|
||||
const footerText = 'Built by Tomato6966 - SourceCode';
|
||||
const link = 'https://github.com/Tomato6966/investment-portfolio-simulator';
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setTextColor(100);
|
||||
const pageHeight = doc.internal.pageSize.height;
|
||||
|
||||
// Add to all pages
|
||||
// @ts-expect-error - doc.internal.getNumberOfPages() is not typed
|
||||
const totalPages = doc.internal.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
|
||||
// Footer text with link
|
||||
doc.text(footerText, 15, pageHeight - 10);
|
||||
|
||||
// Add link annotation
|
||||
doc.link(15, pageHeight - 15, doc.getTextWidth(footerText), 10, { url: link });
|
||||
|
||||
// Page numbers
|
||||
doc.text(`Page ${i} of ${totalPages}`, doc.internal.pageSize.width - 30, pageHeight - 10);
|
||||
}
|
||||
|
||||
doc.save('portfolio-analysis.pdf');
|
||||
};
|
||||
|
|
|
@ -1,41 +1,41 @@
|
|||
import { formatDate, isValid, parseISO } from "date-fns";
|
||||
|
||||
export const formatCurrency = (value: number): string => {
|
||||
return `€${value.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})}`;
|
||||
};
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
export 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 formatDateToISO = (date: Date) => formatDate(date, 'yyyy-MM-dd');
|
||||
export const isValidDate = (dateString: string) => isValid(parseISO(dateString));
|
||||
import { formatDate, isValid, parseISO } from "date-fns";
|
||||
|
||||
export const formatCurrency = (value: number): string => {
|
||||
return `€${value.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})}`;
|
||||
};
|
||||
|
||||
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'
|
||||
];
|
||||
|
||||
export 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 formatDateToISO = (date: Date) => formatDate(date, 'yyyy-MM-dd');
|
||||
export const isValidDate = (dateString: string) => isValid(parseISO(dateString));
|
||||
|
|
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
|
@ -1 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite/client" />
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
],
|
||||
};
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,24 +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"]
|
||||
}
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,22 +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"]
|
||||
}
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const isDev = mode === 'development';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
server: isDev ? {
|
||||
proxy: {
|
||||
'/yahoo': {
|
||||
target: 'https://query1.finance.yahoo.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/yahoo/, ''),
|
||||
headers: {
|
||||
'Origin': 'https://finance.yahoo.com'
|
||||
}
|
||||
}
|
||||
}
|
||||
} : undefined,
|
||||
base: env.VITE_BASE_URL || '/',
|
||||
};
|
||||
});
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
const isDev = mode === 'development';
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
server: isDev ? {
|
||||
proxy: {
|
||||
'/yahoo': {
|
||||
target: 'https://query1.finance.yahoo.com',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/yahoo/, ''),
|
||||
headers: {
|
||||
'Origin': 'https://finance.yahoo.com'
|
||||
}
|
||||
}
|
||||
}
|
||||
} : undefined,
|
||||
base: env.VITE_BASE_URL || '/',
|
||||
};
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue