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:
2026-08-30 19:18:46 +02:00
commit 1dae56415c
37 changed files with 14106 additions and 0 deletions

24
.gitignore vendored Normal file
View File

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

8
.oxlintrc.json Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"inceptionDate": "2026-08-11",
"forecastYears": 10,
"returnAssumption": "custom",
"customAnnualReturnPct": 7
}

10062
data/sp500.json Normal file

File diff suppressed because it is too large Load Diff

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/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

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"),
};

View 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>
);
}

View 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&amp;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>
);
}

View 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
View 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 };
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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>,
)

View 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
View 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&amp;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
View 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
View File

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

23
tsconfig.node.json Normal file
View 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
View 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()],
})