Files
InvestmentsVisualizer/server/apiPlugin.ts
Dasemu 1dae56415c 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>
2026-08-30 19:18:46 +02:00

201 lines
7.1 KiB
TypeScript

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