Initial commit: investment dashboard with contributions panel and forecast chart
React + Vite frontend with a local-JSON-backed API (Vite middleware) for tracking portfolio holdings, contributions/withdrawals, recurring contribution plans, and a forecast chart comparing the portfolio's real value against a same-contributions S&P 500 benchmark. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
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?
|
||||
8
.oxlintrc.json
Normal file
8
.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Investment Visualizer
|
||||
|
||||
A personal investment dashboard: track contributions, see asset allocation, and
|
||||
compare your portfolio's performance against the S&P 500.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend**: React + TypeScript + Vite, charts with Recharts.
|
||||
- **Backend**: a small REST API implemented as a Vite dev-server middleware
|
||||
(`server/apiPlugin.ts`) backed by local JSON files in `data/`. No database,
|
||||
no separate process — `npm run dev` starts both.
|
||||
- **Data**: everything lives under `data/*.json`. The API layer
|
||||
(`src/api/client.ts`) is the only place that knows the data lives in local
|
||||
files — swapping it for a real backend later means only changing that file.
|
||||
|
||||
## Data model (`data/`)
|
||||
|
||||
- `portfolio.json` — current holdings snapshot (values, asset class, region,
|
||||
RV/RF allocation).
|
||||
- `history.json` — periodic total-account-value snapshots, used to compute
|
||||
time-weighted return (TWR). Add more snapshots over time for a more accurate
|
||||
performance line.
|
||||
- `contributions.json` — the movements ledger (external contributions/
|
||||
withdrawals plus internal fund subscriptions/redemptions) and recurring
|
||||
contribution plans (weekly/monthly/etc.) used to project future cash flows.
|
||||
- `settings.json` — portfolio inception date, forecast horizon (years), and
|
||||
the assumed annual return used for the projected part of the chart
|
||||
(historical TWR-derived or a manual assumption).
|
||||
- `sp500.json` — cached daily S&P 500 closes, refreshed with:
|
||||
|
||||
```bash
|
||||
node scripts/fetch-sp500.mjs
|
||||
```
|
||||
|
||||
## Performance methodology
|
||||
|
||||
- **Portfolio return**: simplified chain-linked time-weighted return (TWR)
|
||||
computed from `history.json` snapshots and external cash flows in
|
||||
`contributions.json`. Internal fund subscriptions/redemptions net to zero on
|
||||
total account value and don't affect it.
|
||||
- **S&P 500 line**: percentage change of the index since the portfolio's
|
||||
inception date, for an apples-to-apples "who's outperforming whom"
|
||||
comparison against the portfolio's TWR line.
|
||||
- **Forecast segment**: continues the portfolio line past today using either
|
||||
the historical annualized TWR or a manual rate, applied to the current value
|
||||
plus projected future contributions from active recurring plans.
|
||||
- **0% baseline**: cumulative net contributions (actual + projected) with no
|
||||
investment return at all — the reference line for "what if it just sat in
|
||||
cash."
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This starts Vite (frontend + local JSON API) on http://localhost:5173.
|
||||
133
data/contributions.json
Normal file
133
data/contributions.json
Normal file
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"recurringPlans": [
|
||||
{
|
||||
"id": "207651b6-78ef-4e53-9a89-e877606e2e8e",
|
||||
"amount": 250,
|
||||
"frequency": "monthly",
|
||||
"startDate": "2026-09-05",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"id": "0fab786b-962d-4ed8-bf7a-4c5973212239",
|
||||
"amount": 100,
|
||||
"frequency": "weekly",
|
||||
"startDate": "2026-09-07",
|
||||
"active": true,
|
||||
"note": "Karaoke"
|
||||
}
|
||||
],
|
||||
"movements": [
|
||||
{
|
||||
"id": "m1",
|
||||
"date": "2026-08-09",
|
||||
"kind": "apertura",
|
||||
"amount": 0,
|
||||
"description": "Apertura de la cartera"
|
||||
},
|
||||
{
|
||||
"id": "m2",
|
||||
"date": "2026-08-11",
|
||||
"kind": "aportacion",
|
||||
"amount": 8000,
|
||||
"description": "Abono por traspaso: aportación a mi cartera"
|
||||
},
|
||||
{
|
||||
"id": "m3",
|
||||
"date": "2026-08-12",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 398,
|
||||
"description": "Suscripción BGIF EM Mkt Gover Bond Index D"
|
||||
},
|
||||
{
|
||||
"id": "m4",
|
||||
"date": "2026-08-13",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 2706.4,
|
||||
"description": "Suscripción Ishares US Index Fund IE S EUR"
|
||||
},
|
||||
{
|
||||
"id": "m5",
|
||||
"date": "2026-08-13",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 1194,
|
||||
"description": "Suscripción Ishares ESG Screened Glb Corp"
|
||||
},
|
||||
{
|
||||
"id": "m6",
|
||||
"date": "2026-08-13",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 636.8,
|
||||
"description": "Suscripción Fidelity MSCI Europe Index P A"
|
||||
},
|
||||
{
|
||||
"id": "m7",
|
||||
"date": "2026-08-13",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 796,
|
||||
"description": "Suscripción Vanguard 20+ Year Euro Treasury"
|
||||
},
|
||||
{
|
||||
"id": "m8",
|
||||
"date": "2026-08-13",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 796,
|
||||
"description": "Suscripción Vanguard US Government Bond Index"
|
||||
},
|
||||
{
|
||||
"id": "m9",
|
||||
"date": "2026-08-14",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 636.8,
|
||||
"description": "Suscripción Ishares Emerging Markets Index"
|
||||
},
|
||||
{
|
||||
"id": "m10",
|
||||
"date": "2026-08-14",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 636.8,
|
||||
"description": "Suscripción Fidelity MSCI Japan Index P Ac"
|
||||
},
|
||||
{
|
||||
"id": "m11",
|
||||
"date": "2026-08-14",
|
||||
"kind": "suscripcion_fondo",
|
||||
"amount": 138.38,
|
||||
"description": "Suscripción Fidelity MSCI Pacific Ex-Japan Idx"
|
||||
},
|
||||
{
|
||||
"id": "m12",
|
||||
"date": "2026-08-18",
|
||||
"kind": "aportacion",
|
||||
"amount": 100,
|
||||
"description": "Transferencia Sepa Dario Sevilla Muñoz"
|
||||
},
|
||||
{
|
||||
"id": "m13",
|
||||
"date": "2026-08-18",
|
||||
"kind": "aportacion",
|
||||
"amount": 350,
|
||||
"description": "Transferencia Sepa Dario Sevilla Muñoz"
|
||||
},
|
||||
{
|
||||
"id": "m14",
|
||||
"date": "2026-08-18",
|
||||
"kind": "aportacion",
|
||||
"amount": 100,
|
||||
"description": "Transferencia Sepa Dario Sevilla Muñoz"
|
||||
},
|
||||
{
|
||||
"id": "m15",
|
||||
"date": "2026-08-25",
|
||||
"kind": "aportacion",
|
||||
"amount": 100,
|
||||
"description": "Transferencia Sepa Dario Sevilla Muñoz"
|
||||
},
|
||||
{
|
||||
"id": "m16",
|
||||
"date": "2026-08-27",
|
||||
"kind": "reembolso_fondo",
|
||||
"amount": 301.1,
|
||||
"description": "Reembolso Ishares ESG Screened Glb Corp"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
data/history.json
Normal file
6
data/history.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"snapshots": [
|
||||
{ "date": "2026-08-11", "marketValue": 8000.00 },
|
||||
{ "date": "2026-08-29", "marketValue": 8596.15 }
|
||||
]
|
||||
}
|
||||
26
data/portfolio.json
Normal file
26
data/portfolio.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"asOf": "2026-08-29",
|
||||
"cash": 1011.92,
|
||||
"invested": 7638.08,
|
||||
"marketValueChange": -53.85,
|
||||
"marketValueChangePercent": -0.71,
|
||||
"assetAllocation": {
|
||||
"Renta Variable": 74.43,
|
||||
"Renta Fija": 25.57
|
||||
},
|
||||
"holdings": [
|
||||
{ "name": "Ishares Us Equity Index Fund Clase S", "value": 2676.25, "changeAmount": -30.13, "changePercent": -1.11, "assetClass": "Renta Variable", "region": "EE.UU." },
|
||||
{ "name": "Fidelity S&P 500 Index Fund P-Acc-EUR", "value": 982.14, "changeAmount": 8.8, "changePercent": 0.9, "assetClass": "Renta Variable", "region": "EE.UU." },
|
||||
{ "name": "Ishares ESG Screened Global Corporate Bond Index Fund EUR Hedged", "value": 894.65, "changeAmount": 1.39, "changePercent": 0.16, "assetClass": "Renta Fija", "region": "Global" },
|
||||
{ "name": "Ishares Emerging Markets Index Fund (IE) Acc EUR Clase S", "value": 641.85, "changeAmount": 5.16, "changePercent": 0.81, "assetClass": "Renta Variable", "region": "Emergentes" },
|
||||
{ "name": "Goldman Sachs Global Enhanced Index Sustainable Equity - I Cap EUR", "value": 417.91, "changeAmount": 2.52, "changePercent": 0.61, "assetClass": "Renta Variable", "region": "Global" },
|
||||
{ "name": "Ishares EM Government Bond Index Fund", "value": 395.35, "changeAmount": -2.45, "changePercent": -0.62, "assetClass": "Renta Fija", "region": "Emergentes" },
|
||||
{ "name": "Vanguard US Government Bond Index Fund EUR Hedged", "value": 353.64, "changeAmount": 1.01, "changePercent": 0.29, "assetClass": "Renta Fija", "region": "EE.UU." },
|
||||
{ "name": "Ishares Emerging Markets Index Fund (IE) D Acc EUR", "value": 326.14, "changeAmount": 0.0, "changePercent": 0.0, "assetClass": "Renta Variable", "region": "Emergentes" },
|
||||
{ "name": "Vanguard 20+ Year Euro Treasury Index Fund", "value": 295.95, "changeAmount": -5.3, "changePercent": -1.76, "assetClass": "Renta Fija", "region": "Europa" },
|
||||
{ "name": "Fidelity MSCI Japan Index Fund P-Acc-EUR", "value": 274.12, "changeAmount": -7.73, "changePercent": -2.74, "assetClass": "Renta Variable", "region": "Japón" },
|
||||
{ "name": "Fidelity MSCI Europe Index Fund P-Acc-EUR", "value": 206.43, "changeAmount": -2.24, "changePercent": -1.08, "assetClass": "Renta Variable", "region": "Europa" },
|
||||
{ "name": "Fidelity MSCI Pacific Ex-Japan Index Fund P-Acc-USD", "value": 59.92, "changeAmount": 0.37, "changePercent": 0.62, "assetClass": "Renta Variable", "region": "Asia-Pacífico" },
|
||||
{ "name": "Vanguard US Equity Index Fund EUR Hedged", "value": 59.86, "changeAmount": 0.42, "changePercent": 0.7, "assetClass": "Renta Variable", "region": "EE.UU." }
|
||||
]
|
||||
}
|
||||
6
data/settings.json
Normal file
6
data/settings.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"inceptionDate": "2026-08-11",
|
||||
"forecastYears": 10,
|
||||
"returnAssumption": "custom",
|
||||
"customAnnualReturnPct": 7
|
||||
}
|
||||
10062
data/sp500.json
Normal file
10062
data/sp500.json
Normal file
File diff suppressed because it is too large
Load Diff
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="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mis inversiones</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1748
package-lock.json
generated
Normal file
1748
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "investmentvisualizer",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"recharts": "^3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
33
scripts/fetch-sp500.mjs
Normal file
33
scripts/fetch-sp500.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Refreshes data/sp500.json with daily S&P 500 close prices from Yahoo Finance's
|
||||
// public chart endpoint (no API key required). Run with: node scripts/fetch-sp500.mjs
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_PATH = join(__dirname, "..", "data", "sp500.json");
|
||||
const URL =
|
||||
"https://query1.finance.yahoo.com/v8/finance/chart/%5EGSPC?range=10y&interval=1d";
|
||||
|
||||
const res = await fetch(URL, { headers: { "User-Agent": "Mozilla/5.0" } });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Yahoo Finance request failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const result = data?.chart?.result?.[0];
|
||||
if (!result) {
|
||||
throw new Error("Unexpected response shape from Yahoo Finance");
|
||||
}
|
||||
|
||||
const { timestamp } = result;
|
||||
const closes = result.indicators.quote[0].close;
|
||||
|
||||
const series = [];
|
||||
for (let i = 0; i < timestamp.length; i++) {
|
||||
if (closes[i] == null) continue;
|
||||
const date = new Date(timestamp[i] * 1000).toISOString().slice(0, 10);
|
||||
series.push({ date, close: Number(closes[i].toFixed(2)) });
|
||||
}
|
||||
|
||||
writeFileSync(OUT_PATH, JSON.stringify({ source: "yahoo-finance:^GSPC", updatedAt: new Date().toISOString(), series }, null, 2));
|
||||
console.log(`Wrote ${series.length} points to ${OUT_PATH}`);
|
||||
200
server/apiPlugin.ts
Normal file
200
server/apiPlugin.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin, Connect } from "vite";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readJson, writeJson } from "./jsonStore.ts";
|
||||
import type { Contributions, History, Movement, Portfolio, RecurringPlan, Settings } from "./types.ts";
|
||||
|
||||
function send(res: ServerResponse, status: number, body: unknown): void {
|
||||
const payload = JSON.stringify(body);
|
||||
res.statusCode = status;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(payload);
|
||||
}
|
||||
|
||||
function readBody(req: IncomingMessage): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
req.on("data", (chunk) => (raw += chunk));
|
||||
req.on("end", () => {
|
||||
if (!raw) return resolve({});
|
||||
try {
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
type Handler = (req: IncomingMessage, res: ServerResponse, params: Record<string, string>) => Promise<void> | void;
|
||||
|
||||
interface Route {
|
||||
method: string;
|
||||
pattern: RegExp;
|
||||
paramNames: string[];
|
||||
handler: Handler;
|
||||
}
|
||||
|
||||
function route(method: string, path: string, handler: Handler): Route {
|
||||
const paramNames: string[] = [];
|
||||
const patternStr = path
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
if (segment.startsWith(":")) {
|
||||
paramNames.push(segment.slice(1));
|
||||
return "([^/]+)";
|
||||
}
|
||||
return segment;
|
||||
})
|
||||
.join("/");
|
||||
return { method, pattern: new RegExp(`^${patternStr}$`), paramNames, handler };
|
||||
}
|
||||
|
||||
export function apiPlugin(): Plugin {
|
||||
const routes: Route[] = [
|
||||
route("GET", "/api/portfolio", (_req, res) => {
|
||||
send(res, 200, readJson<Portfolio>("portfolio.json"));
|
||||
}),
|
||||
|
||||
route("GET", "/api/history", (_req, res) => {
|
||||
send(res, 200, readJson<History>("history.json"));
|
||||
}),
|
||||
|
||||
route("POST", "/api/history/snapshots", async (req, res) => {
|
||||
const body = await readBody(req);
|
||||
if (typeof body.date !== "string" || typeof body.marketValue !== "number") {
|
||||
return send(res, 400, { error: "date (string) y marketValue (number) son obligatorios" });
|
||||
}
|
||||
const history = readJson<History>("history.json");
|
||||
const withoutSameDate = history.snapshots.filter((s) => s.date !== body.date);
|
||||
const snapshots = [...withoutSameDate, { date: body.date, marketValue: body.marketValue }].sort((a, b) =>
|
||||
a.date.localeCompare(b.date),
|
||||
);
|
||||
const next: History = { snapshots };
|
||||
writeJson("history.json", next);
|
||||
send(res, 201, next);
|
||||
}),
|
||||
|
||||
route("DELETE", "/api/history/snapshots/:date", (_req, res, params) => {
|
||||
const history = readJson<History>("history.json");
|
||||
const snapshots = history.snapshots.filter((s) => s.date !== params.date);
|
||||
const next: History = { snapshots };
|
||||
writeJson("history.json", next);
|
||||
send(res, 200, next);
|
||||
}),
|
||||
|
||||
route("GET", "/api/contributions", (_req, res) => {
|
||||
send(res, 200, readJson<Contributions>("contributions.json"));
|
||||
}),
|
||||
|
||||
route("POST", "/api/contributions/movements", async (req, res) => {
|
||||
const body = await readBody(req);
|
||||
if (typeof body.date !== "string" || typeof body.amount !== "number" || !body.kind) {
|
||||
return send(res, 400, { error: "date, amount y kind son obligatorios" });
|
||||
}
|
||||
const data = readJson<Contributions>("contributions.json");
|
||||
const movement: Movement = {
|
||||
id: randomUUID(),
|
||||
date: body.date,
|
||||
kind: body.kind,
|
||||
amount: body.amount,
|
||||
description: body.description ?? undefined,
|
||||
};
|
||||
data.movements = [...data.movements, movement].sort((a, b) => a.date.localeCompare(b.date));
|
||||
writeJson("contributions.json", data);
|
||||
send(res, 201, movement);
|
||||
}),
|
||||
|
||||
route("DELETE", "/api/contributions/movements/:id", (_req, res, params) => {
|
||||
const data = readJson<Contributions>("contributions.json");
|
||||
data.movements = data.movements.filter((m) => m.id !== params.id);
|
||||
writeJson("contributions.json", data);
|
||||
send(res, 200, data);
|
||||
}),
|
||||
|
||||
route("POST", "/api/contributions/plans", async (req, res) => {
|
||||
const body = await readBody(req);
|
||||
if (typeof body.amount !== "number" || !body.frequency || typeof body.startDate !== "string") {
|
||||
return send(res, 400, { error: "amount, frequency y startDate son obligatorios" });
|
||||
}
|
||||
const data = readJson<Contributions>("contributions.json");
|
||||
const plan: RecurringPlan = {
|
||||
id: randomUUID(),
|
||||
amount: body.amount,
|
||||
frequency: body.frequency,
|
||||
startDate: body.startDate,
|
||||
endDate: body.endDate ?? undefined,
|
||||
active: body.active ?? true,
|
||||
note: body.note ?? undefined,
|
||||
};
|
||||
data.recurringPlans = [...data.recurringPlans, plan];
|
||||
writeJson("contributions.json", data);
|
||||
send(res, 201, plan);
|
||||
}),
|
||||
|
||||
route("PATCH", "/api/contributions/plans/:id", async (req, res, params) => {
|
||||
const body = await readBody(req);
|
||||
const data = readJson<Contributions>("contributions.json");
|
||||
const idx = data.recurringPlans.findIndex((p) => p.id === params.id);
|
||||
if (idx === -1) return send(res, 404, { error: "Plan no encontrado" });
|
||||
data.recurringPlans[idx] = { ...data.recurringPlans[idx], ...body, id: params.id };
|
||||
writeJson("contributions.json", data);
|
||||
send(res, 200, data.recurringPlans[idx]);
|
||||
}),
|
||||
|
||||
route("DELETE", "/api/contributions/plans/:id", (_req, res, params) => {
|
||||
const data = readJson<Contributions>("contributions.json");
|
||||
data.recurringPlans = data.recurringPlans.filter((p) => p.id !== params.id);
|
||||
writeJson("contributions.json", data);
|
||||
send(res, 200, data);
|
||||
}),
|
||||
|
||||
route("GET", "/api/settings", (_req, res) => {
|
||||
send(res, 200, readJson<Settings>("settings.json"));
|
||||
}),
|
||||
|
||||
route("PUT", "/api/settings", async (req, res) => {
|
||||
const body = await readBody(req);
|
||||
const current = readJson<Settings>("settings.json");
|
||||
const next: Settings = { ...current, ...body };
|
||||
writeJson("settings.json", next);
|
||||
send(res, 200, next);
|
||||
}),
|
||||
|
||||
route("GET", "/api/sp500", (_req, res) => {
|
||||
send(res, 200, readJson("sp500.json"));
|
||||
}),
|
||||
];
|
||||
|
||||
const middleware: Connect.NextHandleFunction = (req, res, next) => {
|
||||
if (!req.url?.startsWith("/api/")) return next();
|
||||
const [pathname] = req.url.split("?");
|
||||
const method = (req.method ?? "GET").toUpperCase();
|
||||
|
||||
for (const r of routes) {
|
||||
if (r.method !== method) continue;
|
||||
const match = r.pattern.exec(pathname);
|
||||
if (!match) continue;
|
||||
const params: Record<string, string> = {};
|
||||
r.paramNames.forEach((name, i) => (params[name] = decodeURIComponent(match[i + 1])));
|
||||
Promise.resolve(r.handler(req, res, params)).catch((err) => {
|
||||
console.error(err);
|
||||
send(res, 500, { error: "Error interno del servidor", detail: String(err?.message ?? err) });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
send(res, 404, { error: `Ruta no encontrada: ${method} ${pathname}` });
|
||||
};
|
||||
|
||||
return {
|
||||
name: "local-json-api",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(middleware);
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
server.middlewares.use(middleware);
|
||||
},
|
||||
};
|
||||
}
|
||||
17
server/jsonStore.ts
Normal file
17
server/jsonStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const DATA_DIR = join(process.cwd(), "data");
|
||||
|
||||
export function dataPath(file: string): string {
|
||||
return join(DATA_DIR, file);
|
||||
}
|
||||
|
||||
export function readJson<T>(file: string): T {
|
||||
const raw = readFileSync(dataPath(file), "utf-8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
export function writeJson<T>(file: string, data: T): void {
|
||||
writeFileSync(dataPath(file), JSON.stringify(data, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
86
server/types.ts
Normal file
86
server/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export type AssetClass = "Renta Variable" | "Renta Fija" | "Liquidez";
|
||||
|
||||
export interface Holding {
|
||||
name: string;
|
||||
value: number;
|
||||
changeAmount: number;
|
||||
changePercent: number;
|
||||
assetClass: AssetClass;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
asOf: string;
|
||||
cash: number;
|
||||
invested: number;
|
||||
marketValueChange: number;
|
||||
marketValueChangePercent: number;
|
||||
assetAllocation: Record<string, number>;
|
||||
holdings: Holding[];
|
||||
}
|
||||
|
||||
export interface HistorySnapshot {
|
||||
date: string;
|
||||
marketValue: number;
|
||||
}
|
||||
|
||||
export interface History {
|
||||
snapshots: HistorySnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* aportacion/retirada = external cash flow (bank <-> account) and count toward TWR/baseline math.
|
||||
* suscripcion_fondo/reembolso_fondo = internal moves between cash and a fund; net-zero on total
|
||||
* account value, so they're excluded from flow math but still shown in the movements ledger.
|
||||
* apertura = informational account-opening marker.
|
||||
*/
|
||||
export type MovementKind =
|
||||
| "aportacion"
|
||||
| "retirada"
|
||||
| "suscripcion_fondo"
|
||||
| "reembolso_fondo"
|
||||
| "apertura"
|
||||
| "otro";
|
||||
|
||||
export interface Movement {
|
||||
id: string;
|
||||
date: string;
|
||||
kind: MovementKind;
|
||||
amount: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type Frequency = "weekly" | "biweekly" | "monthly" | "quarterly" | "yearly";
|
||||
|
||||
export interface RecurringPlan {
|
||||
id: string;
|
||||
amount: number;
|
||||
frequency: Frequency;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
active: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface Contributions {
|
||||
recurringPlans: RecurringPlan[];
|
||||
movements: Movement[];
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
inceptionDate: string;
|
||||
forecastYears: number;
|
||||
returnAssumption: "historical" | "custom";
|
||||
customAnnualReturnPct?: number;
|
||||
}
|
||||
|
||||
export interface SP500Point {
|
||||
date: string;
|
||||
close: number;
|
||||
}
|
||||
|
||||
export interface SP500Data {
|
||||
source: string;
|
||||
updatedAt: string;
|
||||
series: SP500Point[];
|
||||
}
|
||||
319
src/App.css
Normal file
319
src/App.css
Normal file
@@ -0,0 +1,319 @@
|
||||
.app-shell {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 64px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.app-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 16px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--series-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card + .card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-caption {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.stat-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-tile {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-delta {
|
||||
font-size: 12.5px;
|
||||
margin-top: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat-delta.good {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stat-delta.bad {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.state-message {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.period-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: var(--series-1);
|
||||
border-color: var(--series-1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.legend-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-swatch.dashed {
|
||||
border: 1.5px dashed currentColor;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.movements-table th {
|
||||
text-align: left;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
}
|
||||
|
||||
.movements-table td {
|
||||
padding: 9px 10px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.movements-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--gridline);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tag.in {
|
||||
background: color-mix(in srgb, var(--success) 16%, transparent);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.tag.out {
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--page);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 9px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--series-1);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn.danger-ghost {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 4px 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.btn.danger-ghost:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.plan-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plan-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.plan-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 24px 0 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
38
src/App.tsx
Normal file
38
src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
import { useAppData } from "./hooks/useAppData.ts";
|
||||
import { Dashboard } from "./pages/Dashboard.tsx";
|
||||
import { ContributionsPanel } from "./pages/ContributionsPanel.tsx";
|
||||
|
||||
type Tab = "dashboard" | "contributions";
|
||||
|
||||
function App() {
|
||||
const { data, loading, error, refresh } = useAppData();
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-header">
|
||||
<div>
|
||||
<h1 className="app-title">Mis inversiones</h1>
|
||||
<p className="app-subtitle">{data ? `Datos a ${data.portfolio.asOf}` : "Cargando…"}</p>
|
||||
</div>
|
||||
<nav className="tabs">
|
||||
<button className={`tab ${tab === "dashboard" ? "active" : ""}`} onClick={() => setTab("dashboard")}>
|
||||
Resumen
|
||||
</button>
|
||||
<button className={`tab ${tab === "contributions" ? "active" : ""}`} onClick={() => setTab("contributions")}>
|
||||
Aportaciones
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{loading && <div className="state-message">Cargando datos…</div>}
|
||||
{error && <div className="state-message">Error cargando los datos: {error}</div>}
|
||||
{data && tab === "dashboard" && <Dashboard data={data} />}
|
||||
{data && tab === "contributions" && <ContributionsPanel data={data} onRefresh={refresh} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
38
src/api/client.ts
Normal file
38
src/api/client.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Contributions, History, Movement, Portfolio, RecurringPlan, SP500Data, Settings } from "../../server/types.ts";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...init?.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `Error ${res.status} en ${path}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getPortfolio: () => request<Portfolio>("/api/portfolio"),
|
||||
getHistory: () => request<History>("/api/history"),
|
||||
addSnapshot: (date: string, marketValue: number) =>
|
||||
request<History>("/api/history/snapshots", { method: "POST", body: JSON.stringify({ date, marketValue }) }),
|
||||
removeSnapshot: (date: string) => request<History>(`/api/history/snapshots/${encodeURIComponent(date)}`, { method: "DELETE" }),
|
||||
|
||||
getContributions: () => request<Contributions>("/api/contributions"),
|
||||
addMovement: (movement: Pick<Movement, "date" | "kind" | "amount" | "description">) =>
|
||||
request<Movement>("/api/contributions/movements", { method: "POST", body: JSON.stringify(movement) }),
|
||||
removeMovement: (id: string) => request<Contributions>(`/api/contributions/movements/${id}`, { method: "DELETE" }),
|
||||
|
||||
addPlan: (plan: Omit<RecurringPlan, "id">) =>
|
||||
request<RecurringPlan>("/api/contributions/plans", { method: "POST", body: JSON.stringify(plan) }),
|
||||
updatePlan: (id: string, patch: Partial<RecurringPlan>) =>
|
||||
request<RecurringPlan>(`/api/contributions/plans/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
removePlan: (id: string) => request<Contributions>(`/api/contributions/plans/${id}`, { method: "DELETE" }),
|
||||
|
||||
getSettings: () => request<Settings>("/api/settings"),
|
||||
updateSettings: (patch: Partial<Settings>) =>
|
||||
request<Settings>("/api/settings", { method: "PUT", body: JSON.stringify(patch) }),
|
||||
|
||||
getSP500: () => request<SP500Data>("/api/sp500"),
|
||||
};
|
||||
65
src/components/AllocationPie.tsx
Normal file
65
src/components/AllocationPie.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
|
||||
import { categoricalColor } from "../lib/palette.ts";
|
||||
import { usePrefersDark } from "../hooks/usePrefersDark.ts";
|
||||
|
||||
export interface Slice {
|
||||
name: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Slice[];
|
||||
valueFormatter?: (v: number) => string;
|
||||
}
|
||||
|
||||
function CustomTooltip({ active, payload, valueFormatter }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0];
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface-1)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 12px",
|
||||
fontSize: 12.5,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: p.payload.fill, marginBottom: 2 }}>{p.name}</div>
|
||||
<div style={{ fontVariantNumeric: "tabular-nums" }}>{valueFormatter ? valueFormatter(p.value) : p.value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AllocationPie({ data, valueFormatter }: Props) {
|
||||
const dark = usePrefersDark();
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 20, flexWrap: "wrap" }}>
|
||||
<ResponsiveContainer width={180} height={180}>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={48} outerRadius={80} paddingAngle={1.5} stroke="var(--surface-1)" strokeWidth={2} isAnimationActive={false}>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={categoricalColor(i, dark)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CustomTooltip valueFormatter={valueFormatter} />} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, flex: 1, minWidth: 160 }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={d.name} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 13 }}>
|
||||
<span
|
||||
style={{ width: 10, height: 10, borderRadius: 3, background: categoricalColor(i, dark), flexShrink: 0 }}
|
||||
/>
|
||||
<span style={{ color: "var(--text-secondary)", flex: 1 }}>{d.name}</span>
|
||||
<span style={{ fontVariantNumeric: "tabular-nums", color: "var(--text-primary)" }}>
|
||||
{total > 0 ? `${((d.value / total) * 100).toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
src/components/ForecastChart.tsx
Normal file
153
src/components/ForecastChart.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Brush, CartesianGrid, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import type { ChartSeriesPoint } from "../lib/finance.ts";
|
||||
import { addYears, parseISO, toISODate } from "../lib/dates.ts";
|
||||
import { formatDate, formatDateAxis, formatEUR, formatEURAxis } from "../lib/format.ts";
|
||||
|
||||
interface Props {
|
||||
points: ChartSeriesPoint[];
|
||||
todayISO: string;
|
||||
forecastYears: number;
|
||||
}
|
||||
|
||||
const SERIES_LABEL: Record<string, string> = {
|
||||
sp500Value: "Si hubieras invertido en el S&P 500",
|
||||
portfolioValue: "Tu cartera",
|
||||
portfolioForecastValue: "Tu cartera (previsión)",
|
||||
baselineValue: "Aportado sin rendimiento (0%)",
|
||||
};
|
||||
|
||||
function tsToISO(ts: number): string {
|
||||
return new Date(ts).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatTsAxis(ts: number): string {
|
||||
return formatDateAxis(tsToISO(ts));
|
||||
}
|
||||
|
||||
function CustomTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--surface-1)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
fontSize: 12.5,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.12)",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "var(--text-muted)", marginBottom: 6 }}>{formatDate(tsToISO(label))}</div>
|
||||
{payload
|
||||
.filter((p: any) => p.value !== undefined && p.value !== null)
|
||||
.map((p: any) => (
|
||||
<div key={p.dataKey} style={{ display: "flex", justifyContent: "space-between", gap: 16, color: "var(--text-primary)" }}>
|
||||
<span style={{ color: p.color }}>{SERIES_LABEL[p.dataKey]}</span>
|
||||
<span style={{ fontVariantNumeric: "tabular-nums" }}>{formatEUR(p.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForecastChart({ points, todayISO, forecastYears }: Props) {
|
||||
const today = useMemo(() => parseISO(todayISO), [todayISO]);
|
||||
const [rangeYears, setRangeYears] = useState<number>(Math.min(1, forecastYears));
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const presets = useMemo(() => {
|
||||
const options = [{ label: "Histórico", years: 0 }];
|
||||
for (const y of [1, 5, 10]) {
|
||||
if (y < forecastYears) options.push({ label: `+${y} año${y > 1 ? "s" : ""}`, years: y });
|
||||
}
|
||||
options.push({ label: `Previsión completa (${forecastYears} años)`, years: forecastYears });
|
||||
return options;
|
||||
}, [forecastYears]);
|
||||
|
||||
const visiblePoints = useMemo(() => {
|
||||
const cutoff = toISODate(addYears(today, rangeYears));
|
||||
return points.filter((p) => p.date <= cutoff);
|
||||
}, [points, today, rangeYears]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="chart-toolbar">
|
||||
<div className="period-buttons">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
className={`period-btn ${rangeYears === preset.years ? "active" : ""}`}
|
||||
onClick={() => setRangeYears(preset.years)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn secondary" onClick={() => setExpanded((v) => !v)}>
|
||||
{expanded ? "Reducir" : "Ampliar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ResponsiveContainer width="100%" height={expanded ? 620 : 340}>
|
||||
<LineChart data={visiblePoints} margin={{ top: 8, right: 16, left: 8, bottom: 0 }}>
|
||||
<CartesianGrid stroke="var(--gridline)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="ts"
|
||||
type="number"
|
||||
scale="time"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tickFormatter={formatTsAxis}
|
||||
stroke="var(--baseline)"
|
||||
tick={{ fill: "var(--text-muted)", fontSize: 11.5 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "var(--baseline)" }}
|
||||
tickCount={8}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatEURAxis}
|
||||
stroke="var(--baseline)"
|
||||
tick={{ fill: "var(--text-muted)", fontSize: 11.5 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={68}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<ReferenceLine x={today.getTime()} stroke="var(--text-muted)" strokeDasharray="2 3" label={{ value: "Hoy", position: "insideTopRight", fill: "var(--text-muted)", fontSize: 11 }} />
|
||||
|
||||
<Line type="monotone" dataKey="baselineValue" stroke="var(--text-muted)" strokeWidth={2} strokeDasharray="4 4" dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="sp500Value" stroke="var(--series-1)" strokeWidth={2} dot={false} isAnimationActive={false} connectNulls />
|
||||
<Line type="monotone" dataKey="portfolioValue" stroke="var(--series-3)" strokeWidth={2} dot={false} isAnimationActive={false} connectNulls />
|
||||
<Line type="monotone" dataKey="portfolioForecastValue" stroke="var(--series-3)" strokeWidth={2} strokeDasharray="5 4" dot={false} isAnimationActive={false} connectNulls />
|
||||
|
||||
<Brush
|
||||
dataKey="ts"
|
||||
height={22}
|
||||
travellerWidth={8}
|
||||
tickFormatter={formatTsAxis}
|
||||
stroke="var(--series-1)"
|
||||
fill="var(--surface-1)"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="legend-row">
|
||||
<span className="legend-item">
|
||||
<span className="legend-swatch" style={{ background: "var(--series-1)" }} />
|
||||
Si hubieras invertido tus aportaciones en el S&P 500
|
||||
</span>
|
||||
<span className="legend-item">
|
||||
<span className="legend-swatch" style={{ background: "var(--series-3)" }} />
|
||||
Tu cartera (real)
|
||||
</span>
|
||||
<span className="legend-item">
|
||||
<span className="legend-swatch dashed" style={{ color: "var(--series-3)" }} />
|
||||
Tu cartera (previsión, incluye aportaciones)
|
||||
</span>
|
||||
<span className="legend-item">
|
||||
<span className="legend-swatch dashed" style={{ color: "var(--text-muted)" }} />
|
||||
Aportado sin rendimiento (0%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/components/MovementsList.tsx
Normal file
73
src/components/MovementsList.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Movement, MovementKind } from "../../server/types.ts";
|
||||
import { formatDate, formatEUR } from "../lib/format.ts";
|
||||
|
||||
const KIND_LABEL: Record<MovementKind, string> = {
|
||||
aportacion: "Aportación",
|
||||
retirada: "Retirada",
|
||||
suscripcion_fondo: "Compra de fondo",
|
||||
reembolso_fondo: "Venta de fondo",
|
||||
apertura: "Apertura",
|
||||
otro: "Otro",
|
||||
};
|
||||
|
||||
const KIND_TAG: Record<MovementKind, "in" | "out" | ""> = {
|
||||
aportacion: "in",
|
||||
retirada: "out",
|
||||
suscripcion_fondo: "",
|
||||
reembolso_fondo: "",
|
||||
apertura: "",
|
||||
otro: "",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
movements: Movement[];
|
||||
onDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MovementsList({ movements, onDelete }: Props) {
|
||||
const sorted = [...movements].sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
return (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="movements-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Tipo</th>
|
||||
<th>Descripción</th>
|
||||
<th style={{ textAlign: "right" }}>Importe</th>
|
||||
{onDelete && <th />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{formatDate(m.date)}</td>
|
||||
<td>
|
||||
<span className={`tag ${KIND_TAG[m.kind]}`}>{KIND_LABEL[m.kind]}</span>
|
||||
</td>
|
||||
<td style={{ color: "var(--text-secondary)" }}>{m.description ?? "—"}</td>
|
||||
<td className="amount">{formatEUR(m.amount)}</td>
|
||||
{onDelete && (
|
||||
<td>
|
||||
{m.kind === "aportacion" || m.kind === "retirada" ? (
|
||||
<button className="btn danger-ghost" onClick={() => onDelete(m.id)}>
|
||||
Borrar
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ textAlign: "center", color: "var(--text-muted)", padding: 24 }}>
|
||||
Todavía no hay movimientos
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/hooks/useAppData.ts
Normal file
48
src/hooks/useAppData.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../api/client.ts";
|
||||
import type { Contributions, History, Portfolio, SP500Data, Settings } from "../../server/types.ts";
|
||||
|
||||
export interface AppData {
|
||||
portfolio: Portfolio;
|
||||
history: History;
|
||||
contributions: Contributions;
|
||||
settings: Settings;
|
||||
sp500: SP500Data;
|
||||
}
|
||||
|
||||
interface UseAppData {
|
||||
data: AppData | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useAppData(): UseAppData {
|
||||
const [data, setData] = useState<AppData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const [portfolio, history, contributions, settings, sp500] = await Promise.all([
|
||||
api.getPortfolio(),
|
||||
api.getHistory(),
|
||||
api.getContributions(),
|
||||
api.getSettings(),
|
||||
api.getSP500(),
|
||||
]);
|
||||
setData({ portfolio, history, contributions, settings, sp500 });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { data, loading, error, refresh };
|
||||
}
|
||||
14
src/hooks/usePrefersDark.ts
Normal file
14
src/hooks/usePrefersDark.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function usePrefersDark(): boolean {
|
||||
const [dark, setDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = (e: MediaQueryListEvent) => setDark(e.matches);
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return dark;
|
||||
}
|
||||
110
src/index.css
Normal file
110
src/index.css
Normal file
@@ -0,0 +1,110 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-1: #fcfcfb;
|
||||
--page: #f9f9f7;
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--gridline: #e1e0d9;
|
||||
--baseline: #c3c2b7;
|
||||
--border: rgba(11, 11, 11, 0.1);
|
||||
--success: #006300;
|
||||
--danger: #d03b3b;
|
||||
|
||||
--series-1: #2a78d6;
|
||||
--series-2: #eb6834;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #eda100;
|
||||
--series-5: #e87ba4;
|
||||
--series-6: #008300;
|
||||
--series-7: #4a3aa7;
|
||||
--series-8: #e34948;
|
||||
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
color: var(--text-primary);
|
||||
background: var(--page);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:where(:not([data-theme="light"])) {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--page: #0d0d0d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--gridline: #2c2c2a;
|
||||
--baseline: #383835;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--success: #0ca30c;
|
||||
--danger: #e66767;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--page: #0d0d0d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--gridline: #2c2c2a;
|
||||
--baseline: #383835;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--success: #0ca30c;
|
||||
--danger: #e66767;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button, input, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
85
src/lib/dates.ts
Normal file
85
src/lib/dates.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Frequency } from "../../server/types.ts";
|
||||
|
||||
export function parseISO(iso: string): Date {
|
||||
return new Date(`${iso}T00:00:00Z`);
|
||||
}
|
||||
|
||||
export function toISODate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function daysBetween(a: string, b: string): number {
|
||||
return Math.round((parseISO(b).getTime() - parseISO(a).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
export function addInterval(date: Date, frequency: Frequency): Date {
|
||||
const d = new Date(date.getTime());
|
||||
switch (frequency) {
|
||||
case "weekly":
|
||||
d.setUTCDate(d.getUTCDate() + 7);
|
||||
return d;
|
||||
case "biweekly":
|
||||
d.setUTCDate(d.getUTCDate() + 14);
|
||||
return d;
|
||||
case "monthly":
|
||||
return addMonths(d, 1);
|
||||
case "quarterly":
|
||||
return addMonths(d, 3);
|
||||
case "yearly":
|
||||
return addMonths(d, 12);
|
||||
}
|
||||
}
|
||||
|
||||
function addMonths(date: Date, months: number): Date {
|
||||
const day = date.getUTCDate();
|
||||
const d = new Date(date.getTime());
|
||||
d.setUTCDate(1);
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
const lastDayOfTargetMonth = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
d.setUTCDate(Math.min(day, lastDayOfTargetMonth));
|
||||
return d;
|
||||
}
|
||||
|
||||
export function addYears(date: Date, years: number): Date {
|
||||
return addMonths(date, years * 12);
|
||||
}
|
||||
|
||||
function dedupeSorted(dates: Date[]): Date[] {
|
||||
const uniqueByTime = new Map(dates.map((d) => [d.getTime(), d]));
|
||||
return Array.from(uniqueByTime.values()).sort((a, b) => a.getTime() - b.getTime());
|
||||
}
|
||||
|
||||
/** Fixed-day-count grid from `start` to `end` (inclusive), plus any `mustInclude` dates. */
|
||||
export function stepGrid(start: Date, end: Date, stepDays: number, mustInclude: Date[] = []): Date[] {
|
||||
const dates: Date[] = [];
|
||||
let cursor = new Date(start.getTime());
|
||||
while (cursor.getTime() < end.getTime()) {
|
||||
dates.push(new Date(cursor.getTime()));
|
||||
cursor = new Date(cursor.getTime() + stepDays * 86_400_000);
|
||||
}
|
||||
dates.push(new Date(end.getTime()));
|
||||
for (const d of mustInclude) {
|
||||
if (d.getTime() >= start.getTime() && d.getTime() <= end.getTime()) dates.push(d);
|
||||
}
|
||||
return dedupeSorted(dates);
|
||||
}
|
||||
|
||||
/** Monthly grid from `start` to `end` (inclusive), plus `mustInclude` dates (e.g. "today"). */
|
||||
export function monthlyGrid(start: Date, end: Date, mustInclude: Date[] = []): Date[] {
|
||||
const dates: Date[] = [];
|
||||
let cursor = new Date(start.getTime());
|
||||
while (cursor.getTime() < end.getTime()) {
|
||||
dates.push(new Date(cursor.getTime()));
|
||||
cursor = addMonths(cursor, 1);
|
||||
}
|
||||
dates.push(new Date(end.getTime()));
|
||||
for (const d of mustInclude) {
|
||||
if (d.getTime() >= start.getTime() && d.getTime() <= end.getTime()) dates.push(d);
|
||||
}
|
||||
return dedupeSorted(dates);
|
||||
}
|
||||
|
||||
/** Merges and dedupes several date arrays into one sorted grid. */
|
||||
export function mergeGrids(...grids: Date[][]): Date[] {
|
||||
return dedupeSorted(grids.flat());
|
||||
}
|
||||
213
src/lib/finance.ts
Normal file
213
src/lib/finance.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { History, Movement, RecurringPlan, SP500Point, Settings } from "../../server/types.ts";
|
||||
import { addInterval, daysBetween, mergeGrids, monthlyGrid, parseISO, stepGrid, toISODate } from "./dates.ts";
|
||||
|
||||
/** Only external cash flows count as "capital aportado"; internal fund
|
||||
* subscriptions/redemptions and the opening marker net to zero on total account value. */
|
||||
function signedAmount(m: Movement): number {
|
||||
if (m.kind === "aportacion") return m.amount;
|
||||
if (m.kind === "retirada") return -m.amount;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface ProjectedFlow {
|
||||
date: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/** Future cash flows implied by active recurring plans, strictly after `fromExclusive`. */
|
||||
export function projectFutureFlows(plans: RecurringPlan[], fromExclusive: Date, toInclusive: Date): ProjectedFlow[] {
|
||||
const flows: ProjectedFlow[] = [];
|
||||
for (const plan of plans) {
|
||||
if (!plan.active) continue;
|
||||
const planEnd = plan.endDate ? parseISO(plan.endDate) : toInclusive;
|
||||
const horizon = planEnd.getTime() < toInclusive.getTime() ? planEnd : toInclusive;
|
||||
let occurrence = parseISO(plan.startDate);
|
||||
// Fast-forward to the first occurrence after `fromExclusive` without an unbounded loop.
|
||||
while (occurrence.getTime() <= fromExclusive.getTime()) {
|
||||
occurrence = addInterval(occurrence, plan.frequency);
|
||||
}
|
||||
while (occurrence.getTime() <= horizon.getTime()) {
|
||||
flows.push({ date: toISODate(occurrence), amount: plan.amount });
|
||||
occurrence = addInterval(occurrence, plan.frequency);
|
||||
}
|
||||
}
|
||||
return flows;
|
||||
}
|
||||
|
||||
/** Cumulative net contributions (capital aportado) at date `t`: real movements up to
|
||||
* `t`, plus projected recurring flows for any `t` beyond today. */
|
||||
export function cumulativeContributionsAt(
|
||||
t: Date,
|
||||
movements: Movement[],
|
||||
projectedFlows: ProjectedFlow[],
|
||||
): number {
|
||||
const iso = toISODate(t);
|
||||
let total = 0;
|
||||
for (const m of movements) if (m.date <= iso) total += signedAmount(m);
|
||||
for (const f of projectedFlows) if (f.date <= iso) total += f.amount;
|
||||
return total;
|
||||
}
|
||||
|
||||
export interface GainPoint {
|
||||
date: string;
|
||||
/** valor total / capital aportado at this snapshot. 1 = break-even. */
|
||||
gainRatio: number;
|
||||
}
|
||||
|
||||
/** Simple money-weighted return at each snapshot: valor total vs. capital aportado
|
||||
* to date, no per-period flow chaining. */
|
||||
export function computeGainSeries(history: History, movements: Movement[]): GainPoint[] {
|
||||
const snapshots = [...history.snapshots].sort((a, b) => a.date.localeCompare(b.date));
|
||||
return snapshots.map((s) => {
|
||||
const contributed = cumulativeContributionsAt(parseISO(s.date), movements, []);
|
||||
return { date: s.date, gainRatio: contributed > 0 ? s.marketValue / contributed : 1 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Annualized rate implied by the overall gain from the first to the last snapshot. */
|
||||
export function annualizedRate(gainSeries: GainPoint[]): number {
|
||||
if (gainSeries.length < 2) return 0;
|
||||
const first = gainSeries[0];
|
||||
const last = gainSeries[gainSeries.length - 1];
|
||||
const years = daysBetween(first.date, last.date) / 365.25;
|
||||
if (years <= 0 || first.gainRatio <= 0) return 0;
|
||||
return Math.pow(last.gainRatio / first.gainRatio, 1 / years) - 1;
|
||||
}
|
||||
|
||||
function interpolateGainRatio(gainSeries: GainPoint[], date: Date): number {
|
||||
const t = date.getTime();
|
||||
if (t <= parseISO(gainSeries[0].date).getTime()) return gainSeries[0].gainRatio;
|
||||
for (let i = 1; i < gainSeries.length; i++) {
|
||||
const prevT = parseISO(gainSeries[i - 1].date).getTime();
|
||||
const curT = parseISO(gainSeries[i].date).getTime();
|
||||
if (t <= curT) {
|
||||
const frac = curT === prevT ? 1 : (t - prevT) / (curT - prevT);
|
||||
return gainSeries[i - 1].gainRatio + frac * (gainSeries[i].gainRatio - gainSeries[i - 1].gainRatio);
|
||||
}
|
||||
}
|
||||
return gainSeries[gainSeries.length - 1].gainRatio;
|
||||
}
|
||||
|
||||
/** Portfolio € value at any historical date: capital aportado to date, scaled by the
|
||||
* gain ratio (valor total / aportado) interpolated between the surrounding snapshots. */
|
||||
function portfolioValueAt(t: Date, movements: Movement[], gainSeries: GainPoint[]): number {
|
||||
const contributed = cumulativeContributionsAt(t, movements, []);
|
||||
return contributed * interpolateGainRatio(gainSeries, t);
|
||||
}
|
||||
|
||||
/** € value at `t` if every external flow up to `t` had instead been invested in the
|
||||
* S&P 500 on the day it happened (a "same contributions, different fund" benchmark). */
|
||||
function sp500BenchmarkValueAt(t: Date, movements: Movement[], sp500: SP500Point[]): number | undefined {
|
||||
const iso = toISODate(t);
|
||||
let units = 0;
|
||||
for (const m of movements) {
|
||||
if (m.date > iso) continue;
|
||||
const amount = signedAmount(m);
|
||||
if (amount === 0) continue;
|
||||
const price = closestClose(sp500, m.date);
|
||||
if (!price) continue;
|
||||
units += amount / price;
|
||||
}
|
||||
const priceNow = closestClose(sp500, iso);
|
||||
if (!priceNow) return undefined;
|
||||
return units * priceNow;
|
||||
}
|
||||
|
||||
function closestClose(series: SP500Point[], iso: string): number | undefined {
|
||||
let before: SP500Point | undefined;
|
||||
let after: SP500Point | undefined;
|
||||
for (const p of series) {
|
||||
if (p.date <= iso) before = p;
|
||||
if (p.date >= iso && !after) after = p;
|
||||
}
|
||||
return (before ?? after)?.close;
|
||||
}
|
||||
|
||||
export interface ChartSeriesPoint {
|
||||
date: string;
|
||||
/** Epoch ms, for a genuine time-scale X axis — a category axis would space points
|
||||
* by index instead of by elapsed time, which distorts the mixed daily/monthly grid. */
|
||||
ts: number;
|
||||
sp500Value?: number;
|
||||
portfolioValue?: number;
|
||||
portfolioForecastValue?: number;
|
||||
baselineValue: number;
|
||||
}
|
||||
|
||||
export interface ForecastResult {
|
||||
points: ChartSeriesPoint[];
|
||||
annualizedHistoricalRatePct: number;
|
||||
assumedForecastRatePct: number;
|
||||
}
|
||||
|
||||
export function buildForecast(
|
||||
history: History,
|
||||
contributions: { movements: Movement[]; recurringPlans: RecurringPlan[] },
|
||||
sp500: SP500Point[],
|
||||
settings: Settings,
|
||||
todayRaw: Date = new Date(),
|
||||
): ForecastResult {
|
||||
// Normalize to midnight UTC so "today" collapses onto the same grid point as other
|
||||
// dates (parsed from date-only ISO strings) instead of surviving as a near-duplicate.
|
||||
const today = parseISO(toISODate(todayRaw));
|
||||
const gainSeries = computeGainSeries(history, contributions.movements);
|
||||
const inception = parseISO(settings.inceptionDate);
|
||||
const forecastEnd = new Date(today.getTime());
|
||||
forecastEnd.setUTCFullYear(forecastEnd.getUTCFullYear() + settings.forecastYears);
|
||||
|
||||
const historicalRate = annualizedRate(gainSeries);
|
||||
const forecastRate =
|
||||
settings.returnAssumption === "custom" && typeof settings.customAnnualReturnPct === "number"
|
||||
? settings.customAnnualReturnPct / 100
|
||||
: historicalRate;
|
||||
|
||||
const lastSnapshot = history.snapshots[history.snapshots.length - 1];
|
||||
const lastSnapshotDate = parseISO(lastSnapshot.date);
|
||||
|
||||
const projectedFlows = projectFutureFlows(contributions.recurringPlans, today, forecastEnd);
|
||||
|
||||
// The historical window is usually tiny compared to the multi-year forecast — a
|
||||
// uniform monthly grid would collapse it to 1-2 points and hide the S&P/portfolio
|
||||
// comparison. Use finer steps there (daily/weekly/monthly depending on span) and
|
||||
// keep the forecast segment on a monthly cadence.
|
||||
const historicalSpanDays = daysBetween(settings.inceptionDate, lastSnapshot.date);
|
||||
const historicalStepDays = historicalSpanDays <= 120 ? 1 : historicalSpanDays <= 730 ? 7 : 30;
|
||||
const historicalGrid = stepGrid(inception, lastSnapshotDate, historicalStepDays, [lastSnapshotDate]);
|
||||
const forecastGrid = monthlyGrid(lastSnapshotDate, forecastEnd, [today]);
|
||||
const grid = mergeGrids(historicalGrid, forecastGrid);
|
||||
|
||||
const points: ChartSeriesPoint[] = grid.map((d) => {
|
||||
const iso = toISODate(d);
|
||||
const isFuture = d.getTime() > lastSnapshotDate.getTime();
|
||||
|
||||
let portfolioValue: number | undefined;
|
||||
let portfolioForecastValue: number | undefined;
|
||||
if (!isFuture) {
|
||||
portfolioValue = portfolioValueAt(d, contributions.movements, gainSeries);
|
||||
} else {
|
||||
const years = daysBetween(lastSnapshot.date, iso) / 365.25;
|
||||
const growthOfCurrentValue = lastSnapshot.marketValue * Math.pow(1 + forecastRate, years);
|
||||
const growthOfFutureFlows = projectedFlows
|
||||
.filter((f) => f.date <= iso)
|
||||
.reduce((sum, f) => {
|
||||
const flowYears = daysBetween(f.date, iso) / 365.25;
|
||||
return sum + f.amount * Math.pow(1 + forecastRate, flowYears);
|
||||
}, 0);
|
||||
portfolioForecastValue = growthOfCurrentValue + growthOfFutureFlows;
|
||||
}
|
||||
// Bridge the two segments so the line is visually continuous at "today".
|
||||
if (d.getTime() === lastSnapshotDate.getTime()) portfolioForecastValue = portfolioValue;
|
||||
|
||||
const sp500Value = !isFuture ? sp500BenchmarkValueAt(d, contributions.movements, sp500) : undefined;
|
||||
|
||||
const baselineValue = cumulativeContributionsAt(d, contributions.movements, projectedFlows);
|
||||
|
||||
return { date: iso, ts: d.getTime(), sp500Value, portfolioValue, portfolioForecastValue, baselineValue };
|
||||
});
|
||||
|
||||
return {
|
||||
points,
|
||||
annualizedHistoricalRatePct: historicalRate * 100,
|
||||
assumedForecastRatePct: forecastRate * 100,
|
||||
};
|
||||
}
|
||||
49
src/lib/format.ts
Normal file
49
src/lib/format.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export function formatEUR(value: number): string {
|
||||
return new Intl.NumberFormat("es-ES", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
export function formatEURCompact(value: number): string {
|
||||
return new Intl.NumberFormat("es-ES", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/** Short axis-tick label ("140k €", "8,6k €"). Deliberately narrower than
|
||||
* `formatEURCompact`'s "mil"/"M" wording, which reads ambiguous at small tick widths. */
|
||||
export function formatEURAxis(value: number): string {
|
||||
const abs = Math.abs(value);
|
||||
if (abs >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toLocaleString("es-ES", { maximumFractionDigits: 1 })}M €`;
|
||||
}
|
||||
if (abs >= 1_000) {
|
||||
return `${(value / 1_000).toLocaleString("es-ES", { maximumFractionDigits: 1 })}k €`;
|
||||
}
|
||||
return `${Math.round(value)} €`;
|
||||
}
|
||||
|
||||
export function formatPct(value: number, digits = 1): string {
|
||||
const sign = value > 0 ? "+" : "";
|
||||
return `${sign}${value.toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat("es-ES", { day: "2-digit", month: "short", year: "numeric" }).format(
|
||||
new Date(`${iso}T00:00:00Z`),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatDateShort(iso: string): string {
|
||||
return new Intl.DateTimeFormat("es-ES", { day: "2-digit", month: "short" }).format(new Date(`${iso}T00:00:00Z`));
|
||||
}
|
||||
|
||||
/** Compact axis-tick label that still carries the year ("14 ago 26"). */
|
||||
export function formatDateAxis(iso: string): string {
|
||||
return new Intl.DateTimeFormat("es-ES", { day: "2-digit", month: "short", year: "2-digit" }).format(
|
||||
new Date(`${iso}T00:00:00Z`),
|
||||
);
|
||||
}
|
||||
31
src/lib/palette.ts
Normal file
31
src/lib/palette.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Reference categorical palette from the dataviz skill — fixed hue order, never cycled.
|
||||
export const CATEGORICAL_LIGHT = [
|
||||
"#2a78d6", // 1 blue
|
||||
"#eb6834", // 2 orange
|
||||
"#1baf7a", // 3 aqua
|
||||
"#eda100", // 4 yellow
|
||||
"#e87ba4", // 5 magenta
|
||||
"#008300", // 6 green
|
||||
"#4a3aa7", // 7 violet
|
||||
"#e34948", // 8 red
|
||||
];
|
||||
|
||||
export const CATEGORICAL_DARK = [
|
||||
"#3987e5",
|
||||
"#d95926",
|
||||
"#199e70",
|
||||
"#c98500",
|
||||
"#d55181",
|
||||
"#008300",
|
||||
"#9085e9",
|
||||
"#e66767",
|
||||
];
|
||||
|
||||
export function categoricalColor(index: number, dark: boolean): string {
|
||||
const ramp = dark ? CATEGORICAL_DARK : CATEGORICAL_LIGHT;
|
||||
return ramp[index % ramp.length];
|
||||
}
|
||||
|
||||
export function prefersDark(): boolean {
|
||||
return typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
225
src/pages/ContributionsPanel.tsx
Normal file
225
src/pages/ContributionsPanel.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState } from "react";
|
||||
import type { AppData } from "../hooks/useAppData.ts";
|
||||
import { api } from "../api/client.ts";
|
||||
import type { Frequency, MovementKind } from "../../server/types.ts";
|
||||
import { formatDate, formatEUR } from "../lib/format.ts";
|
||||
import { MovementsList } from "../components/MovementsList.tsx";
|
||||
import { toISODate } from "../lib/dates.ts";
|
||||
|
||||
const FREQUENCY_LABEL: Record<Frequency, string> = {
|
||||
weekly: "Semanal",
|
||||
biweekly: "Quincenal",
|
||||
monthly: "Mensual",
|
||||
quarterly: "Trimestral",
|
||||
yearly: "Anual",
|
||||
};
|
||||
|
||||
export function ContributionsPanel({ data, onRefresh }: { data: AppData; onRefresh: () => Promise<void> }) {
|
||||
const { contributions, settings } = data;
|
||||
|
||||
const [movDate, setMovDate] = useState(toISODate(new Date()));
|
||||
const [movKind, setMovKind] = useState<MovementKind>("aportacion");
|
||||
const [movAmount, setMovAmount] = useState("");
|
||||
const [movDesc, setMovDesc] = useState("");
|
||||
const [savingMov, setSavingMov] = useState(false);
|
||||
|
||||
const [planAmount, setPlanAmount] = useState("");
|
||||
const [planFrequency, setPlanFrequency] = useState<Frequency>("monthly");
|
||||
const [planStart, setPlanStart] = useState(toISODate(new Date()));
|
||||
const [planNote, setPlanNote] = useState("");
|
||||
const [savingPlan, setSavingPlan] = useState(false);
|
||||
|
||||
const [inceptionDate, setInceptionDate] = useState(settings.inceptionDate);
|
||||
const [forecastYears, setForecastYears] = useState(String(settings.forecastYears));
|
||||
const [returnAssumption, setReturnAssumption] = useState(settings.returnAssumption);
|
||||
const [customRate, setCustomRate] = useState(String(settings.customAnnualReturnPct ?? 7));
|
||||
const [savingSettings, setSavingSettings] = useState(false);
|
||||
|
||||
async function submitMovement(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const amount = Number(movAmount);
|
||||
if (!amount || amount <= 0) return;
|
||||
setSavingMov(true);
|
||||
try {
|
||||
await api.addMovement({ date: movDate, kind: movKind, amount, description: movDesc || undefined });
|
||||
setMovAmount("");
|
||||
setMovDesc("");
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setSavingMov(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPlan(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const amount = Number(planAmount);
|
||||
if (!amount || amount <= 0) return;
|
||||
setSavingPlan(true);
|
||||
try {
|
||||
await api.addPlan({ amount, frequency: planFrequency, startDate: planStart, active: true, note: planNote || undefined });
|
||||
setPlanAmount("");
|
||||
setPlanNote("");
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setSavingPlan(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlan(id: string, active: boolean) {
|
||||
await api.updatePlan(id, { active: !active });
|
||||
await onRefresh();
|
||||
}
|
||||
|
||||
async function deletePlan(id: string) {
|
||||
await api.removePlan(id);
|
||||
await onRefresh();
|
||||
}
|
||||
|
||||
async function deleteMovement(id: string) {
|
||||
await api.removeMovement(id);
|
||||
await onRefresh();
|
||||
}
|
||||
|
||||
async function saveSettings(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSavingSettings(true);
|
||||
try {
|
||||
await api.updateSettings({
|
||||
inceptionDate,
|
||||
forecastYears: Number(forecastYears) || 1,
|
||||
returnAssumption,
|
||||
customAnnualReturnPct: Number(customRate) || 0,
|
||||
});
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setSavingSettings(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-title">Nueva aportación o retirada</div>
|
||||
<div className="card-caption">Registra un movimiento puntual de dinero entre tu banco y la cartera</div>
|
||||
<form className="form-grid" onSubmit={submitMovement}>
|
||||
<div className="field">
|
||||
<label>Fecha</label>
|
||||
<input type="date" value={movDate} onChange={(e) => setMovDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Tipo</label>
|
||||
<select value={movKind} onChange={(e) => setMovKind(e.target.value as MovementKind)}>
|
||||
<option value="aportacion">Aportación</option>
|
||||
<option value="retirada">Retirada</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Importe (€)</label>
|
||||
<input type="number" min="0" step="0.01" value={movAmount} onChange={(e) => setMovAmount(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nota (opcional)</label>
|
||||
<input type="text" value={movDesc} onChange={(e) => setMovDesc(e.target.value)} placeholder="p. ej. paga extra" />
|
||||
</div>
|
||||
<button className="btn" type="submit" disabled={savingMov}>
|
||||
{savingMov ? "Guardando…" : "Añadir movimiento"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Aportación recurrente</div>
|
||||
<div className="card-caption">Define un plan periódico (semanal, mensual…) que se usará para proyectar la previsión</div>
|
||||
<form className="form-grid" onSubmit={submitPlan}>
|
||||
<div className="field">
|
||||
<label>Importe (€)</label>
|
||||
<input type="number" min="0" step="0.01" value={planAmount} onChange={(e) => setPlanAmount(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Frecuencia</label>
|
||||
<select value={planFrequency} onChange={(e) => setPlanFrequency(e.target.value as Frequency)}>
|
||||
{Object.entries(FREQUENCY_LABEL).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Desde</label>
|
||||
<input type="date" value={planStart} onChange={(e) => setPlanStart(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Nota (opcional)</label>
|
||||
<input type="text" value={planNote} onChange={(e) => setPlanNote(e.target.value)} placeholder="p. ej. nómina" />
|
||||
</div>
|
||||
<button className="btn" type="submit" disabled={savingPlan}>
|
||||
{savingPlan ? "Guardando…" : "Crear plan"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{contributions.recurringPlans.length > 0 && (
|
||||
<div style={{ marginTop: 18 }}>
|
||||
{contributions.recurringPlans.map((plan) => (
|
||||
<div className="plan-row" key={plan.id}>
|
||||
<div>
|
||||
<strong>{formatEUR(plan.amount)}</strong>{" "}
|
||||
<span className="plan-meta">
|
||||
{FREQUENCY_LABEL[plan.frequency]} · desde {formatDate(plan.startDate)}
|
||||
{plan.note ? ` · ${plan.note}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<span className={`tag ${plan.active ? "in" : ""}`}>{plan.active ? "Activo" : "Pausado"}</span>
|
||||
<button className="btn secondary" onClick={() => togglePlan(plan.id, plan.active)}>
|
||||
{plan.active ? "Pausar" : "Reactivar"}
|
||||
</button>
|
||||
<button className="btn danger-ghost" onClick={() => deletePlan(plan.id)}>
|
||||
Borrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Ajustes de previsión</div>
|
||||
<div className="card-caption">Fecha de inicio de la cartera, horizonte y rentabilidad asumida para la parte proyectada del gráfico</div>
|
||||
<form className="form-grid" onSubmit={saveSettings}>
|
||||
<div className="field">
|
||||
<label>Inicio de la cartera</label>
|
||||
<input type="date" value={inceptionDate} onChange={(e) => setInceptionDate(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Horizonte (años)</label>
|
||||
<input type="number" min="1" max="50" value={forecastYears} onChange={(e) => setForecastYears(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Rentabilidad asumida</label>
|
||||
<select value={returnAssumption} onChange={(e) => setReturnAssumption(e.target.value as "historical" | "custom")}>
|
||||
<option value="custom">Manual</option>
|
||||
<option value="historical">Histórica (TWR anualizada)</option>
|
||||
</select>
|
||||
</div>
|
||||
{returnAssumption === "custom" && (
|
||||
<div className="field">
|
||||
<label>% anual asumido</label>
|
||||
<input type="number" step="0.1" value={customRate} onChange={(e) => setCustomRate(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<button className="btn" type="submit" disabled={savingSettings}>
|
||||
{savingSettings ? "Guardando…" : "Guardar ajustes"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Movimientos</div>
|
||||
<div className="card-caption">Puedes borrar aportaciones o retiradas registradas manualmente</div>
|
||||
<MovementsList movements={contributions.movements} onDelete={deleteMovement} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/pages/Dashboard.tsx
Normal file
100
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AppData } from "../hooks/useAppData.ts";
|
||||
import { buildForecast } from "../lib/finance.ts";
|
||||
import { toISODate } from "../lib/dates.ts";
|
||||
import { formatEUR, formatPct } from "../lib/format.ts";
|
||||
import { ForecastChart } from "../components/ForecastChart.tsx";
|
||||
import { AllocationPie } from "../components/AllocationPie.tsx";
|
||||
import { MovementsList } from "../components/MovementsList.tsx";
|
||||
|
||||
const TOP_HOLDINGS = 7;
|
||||
|
||||
export function Dashboard({ data }: { data: AppData }) {
|
||||
const { portfolio, history, contributions, settings, sp500 } = data;
|
||||
const today = useMemo(() => new Date(), []);
|
||||
|
||||
const forecast = useMemo(
|
||||
() => buildForecast(history, contributions, sp500.series, settings, today),
|
||||
[history, contributions, sp500, settings, today],
|
||||
);
|
||||
|
||||
const totalValue = portfolio.holdings.reduce((s, h) => s + h.value, 0) + portfolio.cash;
|
||||
const totalContributed = portfolio.invested + portfolio.cash;
|
||||
const currentReturnPct = totalContributed > 0 ? ((totalValue - totalContributed) / totalContributed) * 100 : 0;
|
||||
const historyDays = history.snapshots.length >= 2
|
||||
? Math.round((new Date(history.snapshots[history.snapshots.length - 1].date).getTime() - new Date(history.snapshots[0].date).getTime()) / 86_400_000)
|
||||
: 0;
|
||||
|
||||
const holdingsPie = useMemo(() => {
|
||||
const sorted = [...portfolio.holdings].sort((a, b) => b.value - a.value);
|
||||
const top = sorted.slice(0, TOP_HOLDINGS).map((h) => ({ name: h.name, value: h.value }));
|
||||
const restTotal = sorted.slice(TOP_HOLDINGS).reduce((s, h) => s + h.value, 0);
|
||||
return restTotal > 0 ? [...top, { name: "Otros", value: restTotal }] : top;
|
||||
}, [portfolio.holdings]);
|
||||
|
||||
const allocationPie = useMemo(
|
||||
() => Object.entries(portfolio.assetAllocation).map(([name, value]) => ({ name, value })),
|
||||
[portfolio.assetAllocation],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stat-row">
|
||||
<div className="stat-tile">
|
||||
<div className="stat-label">Valor total</div>
|
||||
<div className="stat-value">{formatEUR(totalValue)}</div>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<div className="stat-label">Aportado</div>
|
||||
<div className="stat-value">{formatEUR(totalContributed)}</div>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<div className="stat-label">Rendimiento</div>
|
||||
<div className={`stat-value ${currentReturnPct >= 0 ? "" : ""}`}>{formatPct(currentReturnPct)}</div>
|
||||
<div className={`stat-delta ${portfolio.marketValueChange >= 0 ? "good" : "bad"}`}>
|
||||
{formatEUR(portfolio.marketValueChange)} desde el inicio
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<div className="stat-label">Previsión anual asumida</div>
|
||||
<div className="stat-value">{formatPct(forecast.assumedForecastRatePct)}</div>
|
||||
{settings.returnAssumption === "historical" && (
|
||||
<div className="stat-delta">basada en {historyDays} días de histórico</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Previsión de la cartera</div>
|
||||
<div className="card-caption">
|
||||
Valor de la cartera en euros (aportaciones incluidas) desde el inicio, comparado con qué habría pasado
|
||||
invirtiendo esas mismas aportaciones en el S&P 500, y proyectado a {settings.forecastYears} años vista
|
||||
con las aportaciones programadas.
|
||||
{historyDays > 0 && historyDays < 90 && (
|
||||
<> Aviso: con solo {historyDays} días de histórico la previsión es poco fiable — añade más puntos de historial con el tiempo.</>
|
||||
)}
|
||||
</div>
|
||||
<ForecastChart points={forecast.points} todayISO={toISODate(today)} forecastYears={settings.forecastYears} />
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="card">
|
||||
<div className="card-title">Activos por fondo</div>
|
||||
<div className="card-caption">Peso de cada fondo sobre el valor invertido</div>
|
||||
<AllocationPie data={holdingsPie} valueFormatter={formatEUR} />
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-title">Renta variable vs. renta fija</div>
|
||||
<div className="card-caption">Distribución por tipo de activo</div>
|
||||
<AllocationPie data={allocationPie} valueFormatter={(v) => `${v.toFixed(2)}%`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Movimientos</div>
|
||||
<div className="card-caption">Historial completo de la cartera</div>
|
||||
<MovementsList movements={contributions.movements} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": 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" }
|
||||
]
|
||||
}
|
||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import { apiPlugin } from './server/apiPlugin.ts'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), apiPlugin()],
|
||||
})
|
||||
Reference in New Issue
Block a user