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>
201 lines
7.1 KiB
TypeScript
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);
|
|
},
|
|
};
|
|
}
|