mirror of
https://github.com/Tomato6966/investment-portfolio-simulator.git
synced 2025-04-12 09:38:43 +02:00
Initial commit: Portfolio Simulator Implementation
This commit is contained in:
commit
0912dbdb97
31 changed files with 6492 additions and 0 deletions
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"template": "bolt-vite-react-ts"
|
||||||
|
}
|
8
.bolt/prompt
Normal file
8
.bolt/prompt
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||||
|
|
||||||
|
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||||
|
|
||||||
|
Use icons from lucide-react for logos.
|
||||||
|
|
||||||
|
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
|
||||||
|
|
53
.github/workflows/deploy.yml
vendored
Normal file
53
.github/workflows/deploy.yml
vendored
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main # oder master, je nachdem welchen Branch-Namen Sie verwenden
|
||||||
|
workflow_dispatch: # Ermöglicht manuelles Triggern
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# Erlaubt nur einen gleichzeitigen Deploy
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
env:
|
||||||
|
VITE_BASE_URL: '/${{ github.event.repository.name }}'
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v4
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: './dist'
|
||||||
|
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
13
index.html
Normal file
13
index.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
4472
package-lock.json
generated
Normal file
4472
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
38
package.json
Normal file
38
package.json
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "investment-portfolio-tracker",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.24.1",
|
||||||
|
"date-fns": "^3.3.1",
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"recharts": "^2.12.1",
|
||||||
|
"zustand": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"typescript-eslint": "^8.3.0",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
57
src/App.tsx
Normal file
57
src/App.tsx
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { Moon, Plus, Sun } from "lucide-react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { AddAssetModal } from "./components/AddAssetModal";
|
||||||
|
import { InvestmentFormWrapper } from "./components/InvestmentForm";
|
||||||
|
import { PortfolioChart } from "./components/PortfolioChart";
|
||||||
|
import { PortfolioTable } from "./components/PortfolioTable";
|
||||||
|
import { useDarkMode } from "./providers/DarkModeProvider";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [isAddingAsset, setIsAddingAsset] = useState(false);
|
||||||
|
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app ${isDarkMode ? 'dark' : ''}`}>
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 p-8 transition-colors">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold dark:text-white">Portfolio Simulator</h1>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
className="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
>
|
||||||
|
{isDarkMode ? (
|
||||||
|
<Sun className="w-5 h-5 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-5 h-5 text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAddingAsset(true)}
|
||||||
|
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Add Asset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-8 mb-8 dark:text-gray-300">
|
||||||
|
<div className="col-span-3">
|
||||||
|
<PortfolioChart/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1">
|
||||||
|
<InvestmentFormWrapper />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PortfolioTable />
|
||||||
|
{isAddingAsset && <AddAssetModal onClose={() => setIsAddingAsset(false)} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
118
src/components/AddAssetModal.tsx
Normal file
118
src/components/AddAssetModal.tsx
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { getHistoricalData, searchAssets } from "../services/yahooFinanceService";
|
||||||
|
import { usePortfolioStore } from "../store/portfolioStore";
|
||||||
|
import { Asset } from "../types";
|
||||||
|
|
||||||
|
export const AddAssetModal = ({ onClose }: { onClose: () => void }) => {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<Asset[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { addAsset, dateRange } = usePortfolioStore((state) => ({
|
||||||
|
addAsset: state.addAsset,
|
||||||
|
dateRange: state.dateRange,
|
||||||
|
}));
|
||||||
|
const searchTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
|
||||||
|
const debouncedSearch = useCallback((query: string) => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
handleSearch(query);
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = async (query: string) => {
|
||||||
|
if (query.length < 2) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const results = await searchAssets(query);
|
||||||
|
setSearchResults(results);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching assets:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssetSelect = async (asset: Asset) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const historicalData = await getHistoricalData(
|
||||||
|
asset.symbol,
|
||||||
|
dateRange.startDate,
|
||||||
|
dateRange.endDate
|
||||||
|
);
|
||||||
|
|
||||||
|
const assetWithHistory = {
|
||||||
|
...asset,
|
||||||
|
historicalData,
|
||||||
|
};
|
||||||
|
|
||||||
|
addAsset(assetWithHistory);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching historical data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-lg">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold dark:text-gray-200">Add Asset</h2>
|
||||||
|
<button onClick={onClose} className="p-2">
|
||||||
|
<X className="w-6 h-6 dark:text-gray-200" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by symbol or name..."
|
||||||
|
className="w-full p-2 pr-10 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
debouncedSearch(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Search className="absolute right-3 top-2.5 w-5 h-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-4">Loading...</div>
|
||||||
|
) : (
|
||||||
|
searchResults.map((result) => (
|
||||||
|
<button
|
||||||
|
key={result.symbol}
|
||||||
|
className="w-full text-left p-3 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-900 rounded"
|
||||||
|
onClick={() => handleAssetSelect(result)}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{result.name}</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Symbol: {result.symbol}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
41
src/components/DateRangePicker.tsx
Normal file
41
src/components/DateRangePicker.tsx
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
|
||||||
|
|
||||||
|
interface DateRangePickerProps {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
onStartDateChange: (date: string) => void;
|
||||||
|
onEndDateChange: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateRangePicker = ({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange,
|
||||||
|
onEndDateChange,
|
||||||
|
}: DateRangePickerProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 items-center mb-4 dark:text-gray-300">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => onStartDateChange(e.target.value)}
|
||||||
|
max={endDate}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">To</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => onEndDateChange(e.target.value)}
|
||||||
|
min={startDate}
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
79
src/components/EditInvestmentModal.tsx
Normal file
79
src/components/EditInvestmentModal.tsx
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { usePortfolioStore } from "../store/portfolioStore";
|
||||||
|
import { Investment } from "../types";
|
||||||
|
|
||||||
|
interface EditInvestmentModalProps {
|
||||||
|
investment: Investment;
|
||||||
|
assetId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditInvestmentModal = ({ investment, assetId, onClose }: EditInvestmentModalProps) => {
|
||||||
|
const updateInvestment = usePortfolioStore((state) => state.updateInvestment);
|
||||||
|
const [amount, setAmount] = useState(investment.amount.toString());
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateInvestment(assetId, investment.id, {
|
||||||
|
...investment,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold">Edit Investment</h2>
|
||||||
|
<button onClick={onClose} className="p-2">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Investment Amount
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{investment.type === 'periodic' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Note: Editing a periodic investment will affect all future investments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 border rounded hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
262
src/components/InvestmentForm.tsx
Normal file
262
src/components/InvestmentForm.tsx
Normal file
|
@ -0,0 +1,262 @@
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { usePortfolioStore } from "../store/portfolioStore";
|
||||||
|
import { generatePeriodicInvestments } from "../utils/calculations/assetValue";
|
||||||
|
|
||||||
|
export const InvestmentFormWrapper = () => {
|
||||||
|
const [selectedAsset, setSelectedAsset] = useState<string | null>(null);
|
||||||
|
const { assets, clearAssets } = usePortfolioStore((state) => ({
|
||||||
|
assets: state.assets,
|
||||||
|
clearAssets: state.clearAssets,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleClearAssets = () => {
|
||||||
|
if (window.confirm('Are you sure you want to delete all assets? This action cannot be undone.')) {
|
||||||
|
clearAssets();
|
||||||
|
setSelectedAsset(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-slate-800 rounded-lg shadow h-full dark:shadow-black/60">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold dark:text-gray-200">Add Investment</h2>
|
||||||
|
{assets.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearAssets}
|
||||||
|
className="px-3 py-1 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear Assets
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<select
|
||||||
|
value={selectedAsset || ''}
|
||||||
|
disabled={assets.length === 0}
|
||||||
|
onChange={(e) => setSelectedAsset(e.target.value)}
|
||||||
|
className={`w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 ${assets.length === 0 ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<option value="">Select Asset</option>
|
||||||
|
{assets.map((asset) => (
|
||||||
|
<option key={asset.id} value={asset.id}>
|
||||||
|
{asset.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
selectedAsset && (
|
||||||
|
<div className="overflow-y-scroll scrollbar-styled max-h-[380px] p-6 pr-3">
|
||||||
|
<InvestmentForm assetId={selectedAsset} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const InvestmentForm = ({ assetId }: { assetId: string }) => {
|
||||||
|
const [type, setType] = useState<'single' | 'periodic'>('single');
|
||||||
|
const [amount, setAmount] = useState('');
|
||||||
|
const [date, setDate] = useState('');
|
||||||
|
const [dayOfMonth, setDayOfMonth] = useState('1');
|
||||||
|
const [interval, setInterval] = useState('30');
|
||||||
|
const [isDynamic, setIsDynamic] = useState(false);
|
||||||
|
const [dynamicType, setDynamicType] = useState<'percentage' | 'fixed'>('percentage');
|
||||||
|
const [dynamicValue, setDynamicValue] = useState('');
|
||||||
|
const [yearInterval, setYearInterval] = useState('1');
|
||||||
|
|
||||||
|
const { dateRange, addInvestment } = usePortfolioStore((state) => ({
|
||||||
|
dateRange: state.dateRange,
|
||||||
|
addInvestment: state.addInvestment,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (type === "single") {
|
||||||
|
const investment = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
assetId,
|
||||||
|
type,
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
date
|
||||||
|
};
|
||||||
|
addInvestment(assetId, investment);
|
||||||
|
} else {
|
||||||
|
const periodicSettings = {
|
||||||
|
startDate: date,
|
||||||
|
dayOfMonth: parseInt(dayOfMonth),
|
||||||
|
interval: parseInt(interval),
|
||||||
|
amount: parseFloat(amount),
|
||||||
|
...(isDynamic ? {
|
||||||
|
dynamic: {
|
||||||
|
type: dynamicType,
|
||||||
|
value: parseFloat(dynamicValue),
|
||||||
|
yearInterval: parseInt(yearInterval),
|
||||||
|
},
|
||||||
|
} : undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const investments = generatePeriodicInvestments(
|
||||||
|
periodicSettings,
|
||||||
|
new Date(dateRange.endDate),
|
||||||
|
assetId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for(const investment of investments) {
|
||||||
|
addInvestment(assetId, investment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reset form
|
||||||
|
setAmount('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Investment Type</label>
|
||||||
|
<select
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as 'single' | 'periodic')}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value="single">Single Investment</option>
|
||||||
|
<option value="periodic">Periodic Investment</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Amount (€)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'single' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Day of Month</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={dayOfMonth}
|
||||||
|
onChange={(e) => setDayOfMonth(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
min="1"
|
||||||
|
max="31"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Sparplan-Start Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
// the "dayOf the month should not be change able, due to the day of the"
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300 [&::-webkit-calendar-picker-indicator]:dark:invert"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Interval (days)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => setInterval(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
min="14"
|
||||||
|
max="365"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isDynamic}
|
||||||
|
onChange={(e) => setIsDynamic(e.target.checked)}
|
||||||
|
id="dynamic"
|
||||||
|
/>
|
||||||
|
<label htmlFor="dynamic">Enable Periodic Investment Increase</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDynamic && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Increase Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={dynamicType}
|
||||||
|
onChange={(e) => setDynamicType(e.target.value as 'percentage' | 'fixed')}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value="percentage">Percentage (%)</option>
|
||||||
|
<option value="fixed">Fixed Amount (€)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Increase Value
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={dynamicValue}
|
||||||
|
onChange={(e) => setDynamicValue(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
min="0"
|
||||||
|
step={dynamicType === 'percentage' ? '0.1' : '1'}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">
|
||||||
|
Year Interval for Increase
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={yearInterval}
|
||||||
|
onChange={(e) => setYearInterval(e.target.value)}
|
||||||
|
className="w-full p-2 border rounded dark:bg-slate-800 dark:border-slate-700 dark:outline-none dark:text-gray-300"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Add Investment
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
323
src/components/PortfolioChart.tsx
Normal file
323
src/components/PortfolioChart.tsx
Normal file
|
@ -0,0 +1,323 @@
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { BarChart2, Eye, EyeOff, Maximize2, X } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
import { useDarkMode } from "../providers/DarkModeProvider";
|
||||||
|
import { usePortfolioStore } from "../store/portfolioStore";
|
||||||
|
import { calculatePortfolioValue } from "../utils/calculations/portfolioValue";
|
||||||
|
import { DateRangePicker } from "./DateRangePicker";
|
||||||
|
|
||||||
|
const LIGHT_MODE_COLORS = [
|
||||||
|
'#2563eb', '#dc2626', '#059669', '#7c3aed', '#ea580c',
|
||||||
|
'#0891b2', '#be123c', '#1d4ed8', '#b91c1c', '#047857',
|
||||||
|
'#6d28d9', '#c2410c', '#0e7490', '#9f1239', '#1e40af',
|
||||||
|
'#991b1b', '#065f46', '#5b21b6', '#9a3412', '#155e75',
|
||||||
|
'#881337', '#1e3a8a', '#7f1d1d', '#064e3b', '#4c1d95'
|
||||||
|
];
|
||||||
|
|
||||||
|
const DARK_MODE_COLORS = [
|
||||||
|
'#60a5fa', '#f87171', '#34d399', '#a78bfa', '#fb923c',
|
||||||
|
'#22d3ee', '#fb7185', '#3b82f6', '#ef4444', '#10b981',
|
||||||
|
'#8b5cf6', '#f97316', '#06b6d4', '#f43f5e', '#2563eb',
|
||||||
|
'#dc2626', '#059669', '#7c3aed', '#ea580c', '#0891b2',
|
||||||
|
'#be123c', '#1d4ed8', '#b91c1c', '#047857', '#6d28d9'
|
||||||
|
];
|
||||||
|
|
||||||
|
const getHexColor = (usedColors: Set<string>, isDarkMode: boolean): string => {
|
||||||
|
const colorPool = isDarkMode ? DARK_MODE_COLORS : LIGHT_MODE_COLORS;
|
||||||
|
|
||||||
|
// Find first unused color
|
||||||
|
const availableColor = colorPool.find(color => !usedColors.has(color));
|
||||||
|
|
||||||
|
if (availableColor) {
|
||||||
|
return availableColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to random color if all predefined colors are used
|
||||||
|
return `#${Math.floor(Math.random()*16777215).toString(16)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PortfolioChart = () => {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [hideAssets, setHideAssets] = useState(false);
|
||||||
|
const [hiddenAssets, setHiddenAssets] = useState<Set<string>>(new Set());
|
||||||
|
const { isDarkMode } = useDarkMode();
|
||||||
|
const { assets, dateRange, updateDateRange } = usePortfolioStore((state) => ({
|
||||||
|
assets: state.assets,
|
||||||
|
dateRange: state.dateRange,
|
||||||
|
updateDateRange: state.updateDateRange,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const assetColors: Record<string, string> = useMemo(() => {
|
||||||
|
const usedColors = new Set<string>();
|
||||||
|
return assets.reduce((colors, asset) => {
|
||||||
|
const color = getHexColor(usedColors, isDarkMode);
|
||||||
|
usedColors.add(color);
|
||||||
|
return {
|
||||||
|
...colors,
|
||||||
|
[asset.id]: color,
|
||||||
|
};
|
||||||
|
}, {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [assets.map(a => a.id).join(','), isDarkMode]);
|
||||||
|
|
||||||
|
const data = useMemo(() => calculatePortfolioValue(assets, dateRange).filter(v => Object.keys(v.assets).length > 0), [assets, dateRange]);
|
||||||
|
const allAssetsInvestedKapitals = useMemo<Record<string, number>>(() => {
|
||||||
|
const investedKapitals: Record<string, number> = {};
|
||||||
|
|
||||||
|
for(const asset of assets) {
|
||||||
|
investedKapitals[asset.id] = asset.investments.reduce((acc, curr) => acc + curr.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return investedKapitals;
|
||||||
|
}, [assets]);
|
||||||
|
|
||||||
|
// Calculate percentage changes for each asset
|
||||||
|
const processedData = useMemo(() => data.map(point => {
|
||||||
|
const processed: { [key: string]: number | string } = {
|
||||||
|
date: point.date,
|
||||||
|
total: point.total,
|
||||||
|
invested: point.invested,
|
||||||
|
percentageChange: point.percentageChange,
|
||||||
|
};
|
||||||
|
|
||||||
|
processed["ttwor"] = 0;
|
||||||
|
for(const asset of assets) {
|
||||||
|
const initialPrice = data[0].assets[asset.id];
|
||||||
|
const currentPrice = point.assets[asset.id];
|
||||||
|
if (initialPrice && currentPrice) {
|
||||||
|
processed[`${asset.id}_price`] = currentPrice;
|
||||||
|
const percentDecimal = ((currentPrice - initialPrice) / initialPrice);
|
||||||
|
processed[`${asset.id}_percent`] = percentDecimal * 100;
|
||||||
|
processed["ttwor"] += allAssetsInvestedKapitals[asset.id] + allAssetsInvestedKapitals[asset.id] * percentDecimal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processed["ttwor_percent"] = (processed["ttwor"] - Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0)) / Object.values(allAssetsInvestedKapitals).reduce((acc, curr) => acc + curr, 0) * 100;
|
||||||
|
|
||||||
|
|
||||||
|
// add a processed["ttwor"] ttwor is what if you invested all of the kapital of all assets at the start of the period
|
||||||
|
return processed;
|
||||||
|
}), [data, assets, allAssetsInvestedKapitals]);
|
||||||
|
|
||||||
|
|
||||||
|
const toggleAsset = useCallback((assetId: string) => {
|
||||||
|
const newHiddenAssets = new Set(hiddenAssets);
|
||||||
|
if (newHiddenAssets.has(assetId)) {
|
||||||
|
newHiddenAssets.delete(assetId);
|
||||||
|
} else {
|
||||||
|
newHiddenAssets.add(assetId);
|
||||||
|
}
|
||||||
|
setHiddenAssets(newHiddenAssets);
|
||||||
|
}, [hiddenAssets]);
|
||||||
|
|
||||||
|
const toggleAllAssets = useCallback(() => {
|
||||||
|
setHideAssets(!hideAssets);
|
||||||
|
setHiddenAssets(new Set());
|
||||||
|
}, [hideAssets] );
|
||||||
|
|
||||||
|
const CustomLegend = useCallback(({ payload }: any) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-4 rounded-lg shadow-md dark:shadow-black/60">
|
||||||
|
<div className="flex items-center justify-between gap-2 pb-2 border-b">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<BarChart2 className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-sm font-medium">Chart Legend</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={toggleAllAssets}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-sm rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
{hideAssets ? (
|
||||||
|
<>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
Show All
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
Hide All
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{payload.map((entry: any, index: number) => {
|
||||||
|
const assetId = entry.dataKey.split('_')[0];
|
||||||
|
const isHidden = hideAssets || hiddenAssets.has(assetId);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`asset-${index}`}
|
||||||
|
onClick={() => toggleAsset(assetId)}
|
||||||
|
className={`flex items-center gap-2 px-2 py-1 rounded transition-opacity duration-200 ${
|
||||||
|
isHidden ? 'opacity-40' : ''
|
||||||
|
} hover:bg-gray-100 dark:hover:bg-gray-800`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-8 h-[3px]"
|
||||||
|
style={{ backgroundColor: entry.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{entry.value.replace(' (%)', '')}</span>
|
||||||
|
{isHidden ? (
|
||||||
|
<Eye className="w-3 h-3 text-gray-400 dark:text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="w-3 h-3 text-gray-400 dark:text-gray-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [hideAssets, hiddenAssets, toggleAsset, toggleAllAssets]);
|
||||||
|
|
||||||
|
const ChartContent = useCallback(() => (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={dateRange.startDate}
|
||||||
|
endDate={dateRange.endDate}
|
||||||
|
onStartDateChange={(date) => updateDateRange({ ...dateRange, startDate: date })}
|
||||||
|
onEndDateChange={(date) => updateDateRange({ ...dateRange, endDate: date })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={isFullscreen ? "h-[80vh]" : "h-[400px]"}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<LineChart data={processedData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="dark:stroke-slate-600" />
|
||||||
|
<XAxis
|
||||||
|
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(date) => format(new Date(date), 'MMM dd')}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||||
|
yAxisId="left"
|
||||||
|
tickFormatter={(value) => `${value.toLocaleString()}€`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: isDarkMode ? '#D8D8D8' : '#4E4E4E' }}
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tickFormatter={(value) => `${value.toFixed(2)}%`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string, item) => {
|
||||||
|
const assetKey = name.split('_')[0] as keyof typeof assets;
|
||||||
|
const processedKey = `${assets.find(a => a.name === name.replace(" (%)", ""))?.id}_price`;
|
||||||
|
|
||||||
|
if (name === "avg. Portfolio % gain")
|
||||||
|
return [`${value.toFixed(2)}%`, name];
|
||||||
|
|
||||||
|
if (name === "TTWOR")
|
||||||
|
return [`${value.toLocaleString()}€ (${item.payload["ttwor_percent"].toFixed(2)}%)`, name];
|
||||||
|
|
||||||
|
if (name === "Portfolio-Value" || name === "Invested Capital")
|
||||||
|
return [`${value.toLocaleString()}€`, name];
|
||||||
|
|
||||||
|
if (name.includes("(%)"))
|
||||||
|
return [`${Number(item.payload[processedKey]).toFixed(2)}€ ${value.toFixed(2)}%`, name.replace(" (%)", "")];
|
||||||
|
|
||||||
|
return [`${value.toLocaleString()}€ (${((value - Number(assets[assetKey])) / Number(assets[assetKey]) * 100).toFixed(2)}%)`, name];
|
||||||
|
}}
|
||||||
|
labelFormatter={(date) => format(new Date(date), 'MMM dd, yyyy')}
|
||||||
|
/>
|
||||||
|
<Legend content={<CustomLegend />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
name="Portfolio-Value"
|
||||||
|
hide={hideAssets || hiddenAssets.has("total")}
|
||||||
|
stroke="#000"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="invested"
|
||||||
|
name="Invested Capital"
|
||||||
|
hide={hideAssets || hiddenAssets.has("invested")}
|
||||||
|
stroke="#666"
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
dot={false}
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ttwor"
|
||||||
|
name="TTWOR"
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
stroke="#a64c79"
|
||||||
|
hide={hideAssets || hiddenAssets.has("ttwor")}
|
||||||
|
dot={false}
|
||||||
|
yAxisId="left"
|
||||||
|
/>
|
||||||
|
{assets.map((asset) => {
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={asset.id}
|
||||||
|
type="monotone"
|
||||||
|
hide={hideAssets || hiddenAssets.has(asset.id)}
|
||||||
|
dataKey={`${asset.id}_percent`}
|
||||||
|
name={`${asset.name} (%)`}
|
||||||
|
stroke={assetColors[asset.id] || "red"}
|
||||||
|
dot={false}
|
||||||
|
yAxisId="right"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="percentageChange"
|
||||||
|
hide={hideAssets || hiddenAssets.has("percentageChange")}
|
||||||
|
dot={false}
|
||||||
|
name="avg. Portfolio % gain"
|
||||||
|
stroke="#a0a0a0"
|
||||||
|
yAxisId="right"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<i className="text-xs text-gray-500">
|
||||||
|
Note: The YAxis on the left shows the value of your portfolio (black line) and invested capital (dotted line),
|
||||||
|
all other assets are scaled by their % gain/loss and thus scaled to the right YAxis.
|
||||||
|
</i>
|
||||||
|
</>
|
||||||
|
), [assets, isDarkMode, assetColors, hideAssets, hiddenAssets, processedData, CustomLegend, dateRange, updateDateRange, isFullscreen]);
|
||||||
|
|
||||||
|
if (isFullscreen) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-white dark:bg-slate-800 z-50 p-6">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold">Portfolio Chart</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen(false)}
|
||||||
|
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ChartContent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white dark:bg-slate-800 p-4 rounded-lg shadow dark:shadow-black/60">
|
||||||
|
<ChartContent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
225
src/components/PortfolioTable.tsx
Normal file
225
src/components/PortfolioTable.tsx
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { HelpCircle, Pencil, RefreshCw, ShoppingBag, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { usePortfolioStore } from "../store/portfolioStore";
|
||||||
|
import { Investment } from "../types";
|
||||||
|
import { calculateInvestmentPerformance } from "../utils/calculations/performance";
|
||||||
|
import { EditInvestmentModal } from "./EditInvestmentModal";
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
content: string | JSX.Element;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tooltip = ({ content, children }: TooltipProps) => {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 cursor-help"
|
||||||
|
onMouseEnter={() => setShow(true)}
|
||||||
|
onMouseLeave={() => setShow(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<HelpCircle className="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
{show && (
|
||||||
|
<div className="absolute z-50 w-64 p-2 text-sm bg-black text-white rounded shadow-lg dark:shadow-black/60 -left-20 -bottom-2 transform translate-y-full">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PortfolioTable = () => {
|
||||||
|
const { assets, removeInvestment, clearInvestments } = usePortfolioStore((state) => ({
|
||||||
|
assets: state.assets,
|
||||||
|
removeInvestment: state.removeInvestment,
|
||||||
|
clearInvestments: state.clearInvestments,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [editingInvestment, setEditingInvestment] = useState<{
|
||||||
|
investment: Investment;
|
||||||
|
assetId: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const performance = useMemo(() => calculateInvestmentPerformance(assets), [assets]);
|
||||||
|
|
||||||
|
const averagePerformance = useMemo(() => {
|
||||||
|
return (performance.investments.reduce((sum, inv) => sum + inv.performancePercentage, 0) / performance.investments.length).toFixed(2);
|
||||||
|
}, [performance.investments]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback((investmentId: string, assetId: string) => {
|
||||||
|
if (window.confirm("Are you sure you want to delete this investment?")) {
|
||||||
|
removeInvestment(assetId, investmentId);
|
||||||
|
}
|
||||||
|
}, [removeInvestment]);
|
||||||
|
|
||||||
|
const handleClearAll = useCallback(() => {
|
||||||
|
if (window.confirm("Are you sure you want to clear all investments?")) {
|
||||||
|
clearInvestments();
|
||||||
|
}
|
||||||
|
}, [clearInvestments]);
|
||||||
|
|
||||||
|
const performanceTooltip = useMemo(() => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>The performance of your portfolio is {performance.summary.performancePercentage.toFixed(2)}%</p>
|
||||||
|
<p>The average performance of all positions is {averagePerformance}%</p>
|
||||||
|
<p className="text-xs mt-2">
|
||||||
|
Note: An average performance of positions doesn't always match your entire portfolio's average,
|
||||||
|
especially with single investments or investments on different time ranges.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
), [performance.summary.performancePercentage, averagePerformance]);
|
||||||
|
|
||||||
|
const buyInTooltip = useMemo(() => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>"Buy-in" shows the asset's price when that position was bought.</p>
|
||||||
|
<p>"Avg" shows the average buy-in price across all positions for that asset.</p>
|
||||||
|
</div>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const currentAmountTooltip = useMemo(() => (
|
||||||
|
"The current value of your investment based on the latest market price."
|
||||||
|
), []);
|
||||||
|
|
||||||
|
const ttworTooltip = useMemo(() => (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>Time Travel Without Risk (TTWOR) shows how your portfolio would have performed if all investments had been made at the beginning of the period.</p>
|
||||||
|
<p className="text-xs mt-2">
|
||||||
|
It helps to evaluate the impact of your investment timing strategy compared to a single early investment.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto min-h-[500px] dark:text-gray-300 p-4 border-gray-300 dark:border-slate-800 rounded-lg bg-white dark:bg-slate-800 shadow-lg dark:shadow-black/60">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-xl font-bold dark:text-gray-100">Portfolio's <u>Positions</u> Overview</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||||
|
>
|
||||||
|
Clear All Investments
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative rounded-lg overflow-hidden">
|
||||||
|
<table className="min-w-full bg-white dark:bg-slate-800">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 dark:bg-slate-700 text-left">
|
||||||
|
<th className="px-4 py-2 first:rounded-tl-lg">Asset</th>
|
||||||
|
<th className="px-4 py-2">Type</th>
|
||||||
|
<th className="px-4 py-2">Date</th>
|
||||||
|
<th className="px-4 py-2">Invested Amount</th>
|
||||||
|
<th className="px-4 py-2">
|
||||||
|
<Tooltip content={currentAmountTooltip}>
|
||||||
|
Current Amount
|
||||||
|
</Tooltip>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2">
|
||||||
|
<Tooltip content={buyInTooltip}>
|
||||||
|
Buy-In (avg)
|
||||||
|
</Tooltip>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2">
|
||||||
|
<Tooltip content={performanceTooltip}>
|
||||||
|
Performance (%)
|
||||||
|
</Tooltip>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 last:rounded-tr-lg">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{performance.summary && (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<tr className="font-bold bg-gray-50 dark:bg-slate-700 border-t border-gray-200 dark:border-slate-600">
|
||||||
|
<td className="px-4 py-2">Total Portfolio</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2">€{performance.summary.totalInvested.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">€{performance.summary.currentValue.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{performance.summary.performancePercentage.toFixed(2)}%
|
||||||
|
<i className="text-xs text-gray-500 dark:text-gray-400">(avg. {averagePerformance}%)</i>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr className="italic dark:text-gray-500 border-t border-gray-200 dark:border-slate-600 ">
|
||||||
|
<td className="px-4 py-2">TTWOR</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2">{performance.investments[0]?.date}</td>
|
||||||
|
<td className="px-4 py-2">€{performance.summary.totalInvested.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">€{performance.summary.ttworValue.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2"><Tooltip content={ttworTooltip}>{performance.summary.ttworPercentage.toFixed(2)}%</Tooltip></td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{performance.investments.map((inv, index) => {
|
||||||
|
const asset = assets.find(a => a.name === inv.assetName)!;
|
||||||
|
const investment = asset.investments.find(i => i.id === inv.id)! || inv;
|
||||||
|
const filtered = performance.investments.filter(v => v.assetName === inv.assetName);
|
||||||
|
const avgBuyIn = filtered.reduce((acc, curr) => acc + curr.investedAtPrice, 0) / filtered.length;
|
||||||
|
const isLast = index === performance.investments.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={inv.id} className={`border-t border-gray-200 dark:border-slate-600 ${isLast ? 'last:rounded-b-lg' : ''}`}>
|
||||||
|
<td className={`px-4 py-2 ${isLast ? 'first:rounded-bl-lg' : ''}`}>{inv.assetName}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{investment?.type === 'periodic' ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full">
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
Sparplan
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 rounded-full">
|
||||||
|
<ShoppingBag className="w-4 h-4 mr-1" />
|
||||||
|
Einmalig
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">{format(new Date(inv.date), 'dd.MM.yyyy')}</td>
|
||||||
|
<td className="px-4 py-2">€{inv.investedAmount.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">€{inv.currentValue.toFixed(2)}</td>
|
||||||
|
<td className="px-4 py-2">€{inv.investedAtPrice.toFixed(2)} (€{avgBuyIn.toFixed(2)})</td>
|
||||||
|
<td className="px-4 py-2">{inv.performancePercentage.toFixed(2)}%</td>
|
||||||
|
<td className={`px-4 py-2 ${isLast ? 'last:rounded-br-lg' : ''}`}>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingInvestment({ investment, assetId: asset.id })}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(inv.id, asset.id)}
|
||||||
|
className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded text-red-500 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{editingInvestment && (
|
||||||
|
<EditInvestmentModal
|
||||||
|
investment={editingInvestment.investment}
|
||||||
|
assetId={editingInvestment.assetId}
|
||||||
|
onClose={() => setEditingInvestment(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
76
src/index.css
Normal file
76
src/index.css
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Modern Scrollbar Styling */
|
||||||
|
/* Webkit (Chrome, Safari, Edge) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #94a3b8;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure transparent background for the scrollbar area */
|
||||||
|
::-webkit-scrollbar-corner,
|
||||||
|
::-webkit-scrollbar-track-piece {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
.dark ::-webkit-scrollbar {
|
||||||
|
background: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #64748b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #94a3b8 #1d2127;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: #475569 #1d212799 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Internet Explorer */
|
||||||
|
body {
|
||||||
|
-ms-overflow-style: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default white background in dark mode */
|
||||||
|
.dark ::-webkit-scrollbar,
|
||||||
|
.dark ::-webkit-scrollbar-track,
|
||||||
|
.dark ::-webkit-scrollbar-corner,
|
||||||
|
.dark ::-webkit-scrollbar-track-piece {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure the app background extends properly */
|
||||||
|
html, body {
|
||||||
|
background: inherit;
|
||||||
|
}
|
15
src/main.tsx
Normal file
15
src/main.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
import App from "./App.tsx";
|
||||||
|
import { DarkModeProvider } from "./providers/DarkModeProvider.tsx";
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<DarkModeProvider>
|
||||||
|
<App />
|
||||||
|
</DarkModeProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
46
src/providers/DarkModeProvider.tsx
Normal file
46
src/providers/DarkModeProvider.tsx
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface DarkModeContextType {
|
||||||
|
isDarkMode: boolean;
|
||||||
|
toggleDarkMode: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DarkModeContext = createContext<DarkModeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useDarkMode = () => {
|
||||||
|
const context = useContext(DarkModeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDarkMode must be used within a DarkModeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DarkModeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const saved = localStorage.getItem('darkMode');
|
||||||
|
if (saved !== null) {
|
||||||
|
return saved === 'true';
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)')?.matches ?? true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('darkMode', isDarkMode.toString());
|
||||||
|
if (isDarkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [isDarkMode]);
|
||||||
|
|
||||||
|
const toggleDarkMode = () => setIsDarkMode(prev => !prev);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DarkModeContext.Provider value={{ isDarkMode, toggleDarkMode }}>
|
||||||
|
{children}
|
||||||
|
</DarkModeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
108
src/services/yahooFinanceService.ts
Normal file
108
src/services/yahooFinanceService.ts
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import { Asset } from "../types";
|
||||||
|
|
||||||
|
interface YahooQuoteDocument {
|
||||||
|
symbol: string;
|
||||||
|
shortName: string;
|
||||||
|
regularMarketPrice: {
|
||||||
|
raw: number;
|
||||||
|
fmt: string;
|
||||||
|
};
|
||||||
|
regularMarketChange: {
|
||||||
|
raw: number;
|
||||||
|
fmt: string;
|
||||||
|
};
|
||||||
|
regularMarketPercentChange: {
|
||||||
|
raw: number;
|
||||||
|
fmt: string;
|
||||||
|
};
|
||||||
|
exchange: string;
|
||||||
|
quoteType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YahooSearchResponse {
|
||||||
|
finance: {
|
||||||
|
result: [{
|
||||||
|
documents: YahooQuoteDocument[];
|
||||||
|
}];
|
||||||
|
error: null | string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YahooChartResult {
|
||||||
|
timestamp: number[];
|
||||||
|
indicators: {
|
||||||
|
quote: [{
|
||||||
|
close: number[];
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const searchAssets = async (query: string): Promise<Asset[]> => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
query,
|
||||||
|
lang: 'en-US',
|
||||||
|
type: 'equity,etf',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/yahoo/v1/finance/lookup?${params}`);
|
||||||
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
|
|
||||||
|
const data = await response.json() as YahooSearchResponse;
|
||||||
|
|
||||||
|
if (data.finance.error) {
|
||||||
|
throw new Error(data.finance.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.finance.result?.[0]?.documents) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.finance.result[0].documents
|
||||||
|
.filter(quote => quote.quoteType === 'equity' || quote.quoteType === 'etf')
|
||||||
|
.map((quote) => ({
|
||||||
|
id: quote.symbol,
|
||||||
|
isin: '', // not provided by Yahoo Finance API
|
||||||
|
wkn: '', // not provided by Yahoo Finance API
|
||||||
|
name: quote.shortName,
|
||||||
|
symbol: quote.symbol,
|
||||||
|
price: quote.regularMarketPrice.raw,
|
||||||
|
priceChange: quote.regularMarketChange.raw,
|
||||||
|
priceChangePercent: quote.regularMarketPercentChange.raw,
|
||||||
|
exchange: quote.exchange,
|
||||||
|
historicalData: [],
|
||||||
|
investments: [],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching assets:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHistoricalData = async (symbol: string, startDate: string, endDate: string) => {
|
||||||
|
try {
|
||||||
|
const start = Math.floor(new Date(startDate).getTime() / 1000);
|
||||||
|
const end = Math.floor(new Date(endDate).getTime() / 1000);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
period1: start.toString(),
|
||||||
|
period2: end.toString(),
|
||||||
|
interval: '1d',
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/yahoo/v8/finance/chart/${symbol}?${params}`);
|
||||||
|
if (!response.ok) throw new Error('Network response was not ok');
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const { timestamp, indicators } = data.chart.result[0] as YahooChartResult;
|
||||||
|
const quotes = indicators.quote[0];
|
||||||
|
|
||||||
|
return timestamp.map((time: number, index: number) => ({
|
||||||
|
date: new Date(time * 1000).toISOString().split('T')[0],
|
||||||
|
price: quotes.close[index],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching historical data:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
80
src/store/portfolioStore.ts
Normal file
80
src/store/portfolioStore.ts
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
import { format, startOfYear } from "date-fns";
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import { Asset, DateRange, HistoricalData, Investment } from "../types";
|
||||||
|
|
||||||
|
interface PortfolioState {
|
||||||
|
assets: Asset[];
|
||||||
|
dateRange: DateRange;
|
||||||
|
addAsset: (asset: Asset) => void;
|
||||||
|
removeAsset: (assetId: string) => void;
|
||||||
|
clearAssets: () => void;
|
||||||
|
addInvestment: (assetId: string, investment: Investment) => void;
|
||||||
|
removeInvestment: (assetId: string, investmentId: string) => void;
|
||||||
|
updateDateRange: (dateRange: DateRange) => void;
|
||||||
|
updateAssetHistoricalData: (assetId: string, historicalData: HistoricalData[]) => void;
|
||||||
|
updateInvestment: (assetId: string, investmentId: string, updatedInvestment: Investment) => void;
|
||||||
|
clearInvestments: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePortfolioStore = create<PortfolioState>((set) => ({
|
||||||
|
assets: [],
|
||||||
|
dateRange: {
|
||||||
|
startDate: format(startOfYear(new Date()), 'yyyy-MM-dd'),
|
||||||
|
endDate: format(new Date(), 'yyyy-MM-dd'),
|
||||||
|
},
|
||||||
|
addAsset: (asset) =>
|
||||||
|
set((state) => ({ assets: [...state.assets, asset] })),
|
||||||
|
removeAsset: (assetId) =>
|
||||||
|
set((state) => ({
|
||||||
|
assets: state.assets.filter((asset) => asset.id !== assetId),
|
||||||
|
})),
|
||||||
|
clearAssets: () =>
|
||||||
|
set(() => ({ assets: [] })),
|
||||||
|
addInvestment: (assetId, investment) =>
|
||||||
|
set((state) => ({
|
||||||
|
assets: state.assets.map((asset) =>
|
||||||
|
asset.id === assetId
|
||||||
|
? { ...asset, investments: [...asset.investments, investment] }
|
||||||
|
: asset
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
removeInvestment: (assetId, investmentId) =>
|
||||||
|
set((state) => ({
|
||||||
|
assets: state.assets.map((asset) =>
|
||||||
|
asset.id === assetId
|
||||||
|
? {
|
||||||
|
...asset,
|
||||||
|
investments: asset.investments.filter((inv) => inv.id !== investmentId),
|
||||||
|
}
|
||||||
|
: asset
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
updateDateRange: (dateRange) =>
|
||||||
|
set(() => ({ dateRange })),
|
||||||
|
updateAssetHistoricalData: (assetId, historicalData) =>
|
||||||
|
set((state) => ({
|
||||||
|
assets: state.assets.map((asset) =>
|
||||||
|
asset.id === assetId
|
||||||
|
? { ...asset, historicalData }
|
||||||
|
: asset
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
updateInvestment: (assetId, investmentId, updatedInvestment) =>
|
||||||
|
set((state) => ({
|
||||||
|
assets: state.assets.map((asset) =>
|
||||||
|
asset.id === assetId
|
||||||
|
? {
|
||||||
|
...asset,
|
||||||
|
investments: asset.investments.map((inv) =>
|
||||||
|
inv.id === investmentId ? updatedInvestment : inv
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: asset
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
clearInvestments: () =>
|
||||||
|
set((state) => ({
|
||||||
|
assets: state.assets.map((asset) => ({ ...asset, investments: [] })),
|
||||||
|
})),
|
||||||
|
}));
|
50
src/types/index.ts
Normal file
50
src/types/index.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
export interface Asset {
|
||||||
|
id: string;
|
||||||
|
isin: string;
|
||||||
|
name: string;
|
||||||
|
wkn: string;
|
||||||
|
symbol: string;
|
||||||
|
historicalData: HistoricalData[];
|
||||||
|
investments: Investment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalData {
|
||||||
|
date: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Investment {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
type: 'single' | 'periodic';
|
||||||
|
amount: number;
|
||||||
|
date?: string;
|
||||||
|
periodicGroupId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodicSettings {
|
||||||
|
dayOfMonth: number;
|
||||||
|
interval: number;
|
||||||
|
dynamic?: {
|
||||||
|
type: 'percentage' | 'fixed';
|
||||||
|
value: number;
|
||||||
|
yearInterval: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvestmentPerformance {
|
||||||
|
id: string;
|
||||||
|
assetName: string;
|
||||||
|
date: string;
|
||||||
|
investedAmount: number;
|
||||||
|
investedAtPrice: number;
|
||||||
|
currentValue: number;
|
||||||
|
performancePercentage: number;
|
||||||
|
periodicGroupId?: string;
|
||||||
|
avgBuyIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}
|
95
src/utils/calculations/assetValue.ts
Normal file
95
src/utils/calculations/assetValue.ts
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
import { addDays, isAfter, isBefore, isSameDay } from "date-fns";
|
||||||
|
|
||||||
|
import { Asset, Investment } from "../../types";
|
||||||
|
|
||||||
|
export interface PeriodicSettings {
|
||||||
|
startDate: string;
|
||||||
|
dayOfMonth: number;
|
||||||
|
interval: number;
|
||||||
|
amount: number;
|
||||||
|
dynamic?: {
|
||||||
|
type: 'percentage' | 'fixed';
|
||||||
|
value: number;
|
||||||
|
yearInterval: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateAssetValueAtDate = (asset: Asset, date: Date, currentPrice: number) => {
|
||||||
|
let totalShares = 0;
|
||||||
|
|
||||||
|
const buyIns:number[] = [];
|
||||||
|
// Calculate shares for each investment up to the given date
|
||||||
|
for(const investment of asset.investments) {
|
||||||
|
const invDate = new Date(investment.date!);
|
||||||
|
if (isAfter(invDate, date) || isSameDay(invDate, date)) continue;
|
||||||
|
|
||||||
|
// Find price at investment date
|
||||||
|
const investmentPrice = asset.historicalData.find(
|
||||||
|
(data) => data.date === investment.date
|
||||||
|
)?.price || 0;
|
||||||
|
|
||||||
|
// if no investment price found, use the previous price
|
||||||
|
const previousInvestmentPrice = investmentPrice || asset.historicalData
|
||||||
|
.filter(({ date }) => isAfter(new Date(date), invDate) || isSameDay(new Date(date), invDate))
|
||||||
|
.find(({ price }) => price !== 0)?.price || 0;
|
||||||
|
|
||||||
|
const investmentPriceToUse = investmentPrice || previousInvestmentPrice || asset.historicalData
|
||||||
|
.filter(({ date }) => isBefore(new Date(date), invDate) || isSameDay(new Date(date), invDate))
|
||||||
|
.reverse()
|
||||||
|
.find(({ price }) => price !== 0)?.price || 0;
|
||||||
|
|
||||||
|
if (investmentPriceToUse > 0) {
|
||||||
|
totalShares += investment.amount / investmentPriceToUse;
|
||||||
|
buyIns.push(investmentPriceToUse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return current value of all shares
|
||||||
|
return {
|
||||||
|
investedValue: totalShares * currentPrice,
|
||||||
|
avgBuyIn: buyIns.reduce((a, b) => a + b, 0) / buyIns.length,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generatePeriodicInvestments = (settings: PeriodicSettings, endDate: Date, assetId: string): Investment[] => {
|
||||||
|
const investments: Investment[] = [];
|
||||||
|
let currentDate = new Date(settings.startDate);
|
||||||
|
let currentAmount = settings.amount;
|
||||||
|
const periodicGroupId = crypto.randomUUID();
|
||||||
|
|
||||||
|
while (isBefore(currentDate, endDate)) {
|
||||||
|
if (currentDate.getDate() === settings.dayOfMonth) {
|
||||||
|
// Handle dynamic increases if configured
|
||||||
|
if (settings.dynamic) {
|
||||||
|
const yearsSinceStart =
|
||||||
|
(currentDate.getTime() - new Date(settings.startDate).getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24 * 365);
|
||||||
|
|
||||||
|
if (yearsSinceStart >= settings.dynamic.yearInterval) {
|
||||||
|
if (settings.dynamic.type === 'percentage') {
|
||||||
|
currentAmount *= (1 + settings.dynamic.value / 100);
|
||||||
|
} else {
|
||||||
|
currentAmount += settings.dynamic.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Create investment for this date
|
||||||
|
investments.push({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: 'periodic',
|
||||||
|
amount: currentAmount,
|
||||||
|
date: currentDate.toISOString().split('T')[0],
|
||||||
|
periodicGroupId,
|
||||||
|
assetId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move to next interval
|
||||||
|
currentDate = addDays(currentDate, settings.interval);
|
||||||
|
} else {
|
||||||
|
// Move to next day if not the investment day
|
||||||
|
currentDate = addDays(currentDate, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return investments;
|
||||||
|
};
|
105
src/utils/calculations/performance.ts
Normal file
105
src/utils/calculations/performance.ts
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
import { isAfter, isBefore } from "date-fns";
|
||||||
|
|
||||||
|
import { Asset } from "../../types";
|
||||||
|
|
||||||
|
export interface InvestmentPerformance {
|
||||||
|
id: string;
|
||||||
|
assetName: string;
|
||||||
|
date: string;
|
||||||
|
investedAmount: number;
|
||||||
|
investedAtPrice: number;
|
||||||
|
currentValue: number;
|
||||||
|
performancePercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioPerformance {
|
||||||
|
investments: InvestmentPerformance[];
|
||||||
|
summary: {
|
||||||
|
totalInvested: number;
|
||||||
|
currentValue: number;
|
||||||
|
performancePercentage: number;
|
||||||
|
ttworValue: number;
|
||||||
|
ttworPercentage: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateInvestmentPerformance = (assets: Asset[]): PortfolioPerformance => {
|
||||||
|
const investments: InvestmentPerformance[] = [];
|
||||||
|
let totalInvested = 0;
|
||||||
|
let totalCurrentValue = 0;
|
||||||
|
|
||||||
|
// TTWOR Berechnung
|
||||||
|
const firstDayPrices: Record<string, number> = {};
|
||||||
|
const currentPrices: Record<string, number> = {};
|
||||||
|
const investedPerAsset: Record<string, number> = {};
|
||||||
|
|
||||||
|
// Sammle erste und letzte Preise für jedes Asset
|
||||||
|
for(const asset of assets) {
|
||||||
|
firstDayPrices[asset.id] = asset.historicalData[0]?.price || 0;
|
||||||
|
currentPrices[asset.id] = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
||||||
|
investedPerAsset[asset.id] = asset.investments.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechne TTWOR
|
||||||
|
const ttworValue = Object.entries(investedPerAsset).reduce((acc, [assetId, invested]) => {
|
||||||
|
if (firstDayPrices[assetId] && currentPrices[assetId] && firstDayPrices[assetId] > 0) {
|
||||||
|
const shares = invested / firstDayPrices[assetId];
|
||||||
|
return acc + (shares * currentPrices[assetId]);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Normale Performance-Berechnungen...
|
||||||
|
for(const asset of assets) {
|
||||||
|
const currentPrice = asset.historicalData[asset.historicalData.length - 1]?.price || 0;
|
||||||
|
|
||||||
|
for(const investment of asset.investments) {
|
||||||
|
const investmentPrice = asset.historicalData.find(
|
||||||
|
(data) => data.date === investment.date
|
||||||
|
)?.price || 0;
|
||||||
|
|
||||||
|
const previousPrice = investmentPrice || asset.historicalData.filter(
|
||||||
|
(data) => isBefore(new Date(data.date), new Date(investment.date!))
|
||||||
|
).reverse().find((v) => v.price !== 0)?.price || 0;
|
||||||
|
|
||||||
|
const buyInPrice = investmentPrice || previousPrice || asset.historicalData.filter(
|
||||||
|
(data) => isAfter(new Date(data.date), new Date(investment.date!))
|
||||||
|
).find((v) => v.price !== 0)?.price || 0;
|
||||||
|
|
||||||
|
const shares = buyInPrice > 0 ? investment.amount / buyInPrice : 0;
|
||||||
|
const currentValue = shares * currentPrice;
|
||||||
|
|
||||||
|
investments.push({
|
||||||
|
id: investment.id,
|
||||||
|
assetName: asset.name,
|
||||||
|
date: investment.date!,
|
||||||
|
investedAmount: investment.amount,
|
||||||
|
investedAtPrice: buyInPrice,
|
||||||
|
currentValue,
|
||||||
|
performancePercentage: investment.amount > 0
|
||||||
|
? (((currentValue - investment.amount) / investment.amount)) * 100
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalInvested += investment.amount;
|
||||||
|
totalCurrentValue += currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttworPercentage = totalInvested > 0
|
||||||
|
? ((ttworValue - totalInvested) / totalInvested) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
investments,
|
||||||
|
summary: {
|
||||||
|
totalInvested,
|
||||||
|
currentValue: totalCurrentValue,
|
||||||
|
performancePercentage: totalInvested > 0
|
||||||
|
? ((totalCurrentValue - totalInvested) / totalInvested) * 100
|
||||||
|
: 0,
|
||||||
|
ttworValue,
|
||||||
|
ttworPercentage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
79
src/utils/calculations/portfolioValue.ts
Normal file
79
src/utils/calculations/portfolioValue.ts
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
import { addDays, isAfter, isBefore } from "date-fns";
|
||||||
|
|
||||||
|
import { Asset, DateRange } from "../../types";
|
||||||
|
import { calculateAssetValueAtDate } from "./assetValue";
|
||||||
|
|
||||||
|
type DayData = {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
invested: number;
|
||||||
|
percentageChange: number;
|
||||||
|
/* Current price of asset */
|
||||||
|
assets: { [key: string]: number };
|
||||||
|
};
|
||||||
|
export const calculatePortfolioValue = (assets: Asset[], dateRange: DateRange) => {
|
||||||
|
const { startDate, endDate } = dateRange;
|
||||||
|
const data: DayData[] = [];
|
||||||
|
|
||||||
|
let currentDate = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
const beforeValue: { [assetId: string]: number } = {};
|
||||||
|
|
||||||
|
while (isBefore(currentDate, end)) {
|
||||||
|
const dayData: DayData = {
|
||||||
|
date: currentDate.toISOString().split('T')[0],
|
||||||
|
total: 0,
|
||||||
|
invested: 0,
|
||||||
|
percentageChange: 0,
|
||||||
|
assets: {},
|
||||||
|
};
|
||||||
|
// this should contain the percentage gain of all investments till now
|
||||||
|
const pPercents: number[] = [];
|
||||||
|
|
||||||
|
for(const asset of assets) {
|
||||||
|
// calculate the invested kapital
|
||||||
|
for(const investment of asset.investments) {
|
||||||
|
if (!isAfter(new Date(investment.date!), currentDate)) {
|
||||||
|
dayData.invested += investment.amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get historical price for the asset
|
||||||
|
const currentValueOfAsset = asset.historicalData.find(
|
||||||
|
(data) => data.date === dayData.date
|
||||||
|
)?.price || beforeValue[asset.id];
|
||||||
|
beforeValue[asset.id] = currentValueOfAsset;
|
||||||
|
|
||||||
|
if (currentValueOfAsset !== undefined) {
|
||||||
|
const { investedValue, avgBuyIn } = calculateAssetValueAtDate(
|
||||||
|
asset,
|
||||||
|
currentDate,
|
||||||
|
currentValueOfAsset
|
||||||
|
);
|
||||||
|
|
||||||
|
dayData.total += investedValue || 0;
|
||||||
|
dayData.assets[asset.id] = currentValueOfAsset;
|
||||||
|
|
||||||
|
const percent = ((currentValueOfAsset - avgBuyIn) / avgBuyIn) * 100;
|
||||||
|
if(!Number.isNaN(percent)) pPercents.push(percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Calculate average percentage change if percentages array is not empty
|
||||||
|
if (pPercents.length > 0) {
|
||||||
|
dayData.percentageChange = pPercents.reduce((a, b) => a + b, 0) / pPercents.length;
|
||||||
|
} else {
|
||||||
|
dayData.percentageChange = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate = addDays(currentDate, 1);
|
||||||
|
data.push(dayData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out days with incomplete asset data
|
||||||
|
return data.filter(
|
||||||
|
(dayData) => !Object.values(dayData.assets).some((value) => value === 0)
|
||||||
|
);
|
||||||
|
};
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
10
tailwind.config.js
Normal file
10
tailwind.config.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
],
|
||||||
|
};
|
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['lucide-react'],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/yahoo': {
|
||||||
|
target: 'https://query1.finance.yahoo.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/yahoo/, ''),
|
||||||
|
headers: {
|
||||||
|
'Origin': 'https://finance.yahoo.com'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
base: process.env.VITE_BASE_URL || '/',
|
||||||
|
});
|
Loading…
Add table
Reference in a new issue