added api logic, vitest, minimal testing ui

This commit is contained in:
2025-11-15 23:26:57 -06:00
parent f4160b91db
commit 4eae966f96
95 changed files with 14155 additions and 469 deletions

30
web/src/api/categories.ts Normal file
View File

@@ -0,0 +1,30 @@
import { request } from "./client";
export type NewCategory = {
name: string;
percent: number; // 0..100
isSavings: boolean;
priority: number;
};
export type UpdateCategory = Partial<NewCategory>;
export const categoriesApi = {
create: (body: NewCategory) =>
request<{ id: number }>("/variable-categories", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
update: (id: number, body: UpdateCategory) =>
request(`/variable-categories/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
delete: (id: number) =>
request(`/variable-categories/${id}`, {
method: "DELETE"
})
};

30
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,30 @@
export type ApiError = { status: number; message: string };
const base = "/api";
const KEY = "skymoney:userId";
export function getUserId(): string {
let id = localStorage.getItem(KEY);
if (!id) { id = "1"; localStorage.setItem(KEY, id); }
return id;
}
export function setUserId(id: string) {
localStorage.setItem(KEY, String(id || "1"));
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { "Content-Type": "application/json", "x-user-id": getUserId(), ...(init?.headers || {}) },
...init
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw { status: res.status, message: data?.message || res.statusText } as ApiError;
return data as T;
}
export const api = {
get: <T,>(path: string) => request<T>(path),
post: <T,>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
};

27
web/src/api/fixedPlans.ts Normal file
View File

@@ -0,0 +1,27 @@
import { request } from "./client";
export type NewPlan = {
name: string;
totalCents: number; // >= 0
fundedCents?: number; // optional, default 0
priority: number; // int
dueOn: string; // ISO date
};
export type UpdatePlan = Partial<NewPlan>;
export const fixedPlansApi = {
create: (body: NewPlan) =>
request<{ id: number }>("/fixed-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
update: (id: number, body: UpdatePlan) =>
request(`/fixed-plans/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
delete: (id: number) =>
request(`/fixed-plans/${id}`, { method: "DELETE" }),
};

65
web/src/api/http.ts Normal file
View File

@@ -0,0 +1,65 @@
// Lightweight fetch wrappers with sensible defaults
const BASE =
(typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_URL) ||
""; // e.g. "http://localhost:8080" or proxy
type FetchOpts = {
method?: "GET" | "POST" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: any;
query?: Record<string, string | number | boolean | undefined>;
};
function toQS(q?: FetchOpts["query"]) {
if (!q) return "";
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(q)) {
if (v === undefined || v === null || v === "") continue;
sp.set(k, String(v));
}
const s = sp.toString();
return s ? `?${s}` : "";
}
async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const { method = "GET", headers = {}, body, query } = opts;
const url = `${BASE}${path}${toQS(query)}`;
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
// The API defaults x-user-id if missing; add your own if you want:
// "x-user-id": localStorage.getItem("userId") ?? "demo-user-1",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
// Try to parse JSON either way
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
const msg =
(json && (json.message || json.error)) ||
`${res.status} ${res.statusText}` ||
"Request failed";
throw new Error(msg);
}
return json as T;
}
export async function apiGet<T>(path: string, query?: FetchOpts["query"]) {
return http<T>(path, { method: "GET", query });
}
export async function apiPost<T>(path: string, body?: any) {
return http<T>(path, { method: "POST", body });
}
export async function apiPatch<T>(path: string, body?: any) {
return http<T>(path, { method: "PATCH", body });
}
export async function apiDelete<T>(path: string) {
return http<T>(path, { method: "DELETE" });
}

67
web/src/api/schemas.ts Normal file
View File

@@ -0,0 +1,67 @@
import { z } from "zod";
const money = z.number().int().nonnegative();
export const VariableCategory = z.object({
id: z.number().int(),
name: z.string(),
percent: z.number().int(),
isSavings: z.boolean(),
priority: z.number().int(),
balanceCents: money
}).passthrough();
export const FixedPlan = z.object({
id: z.number().int(),
name: z.string(),
totalCents: money,
fundedCents: money,
priority: z.number().int(),
dueOn: z.string() // ISO
}).passthrough();
export const Transaction = z.object({
id: z.number().int(),
kind: z.enum(["variable_spend", "fixed_payment"]).or(z.string()),
amountCents: money,
occurredAt: z.string()
}).passthrough();
export const Dashboard = z.object({
totals: z.object({
incomeCents: money,
variableBalanceCents: money,
fixedRemainingCents: money
}).passthrough(),
variableCategories: z.array(VariableCategory),
fixedPlans: z.array(FixedPlan),
recentTransactions: z.array(Transaction)
}).passthrough();
export const IncomeResult = z.object({
incomeEventId: z.number().int(),
fixedAllocations: z.array(z.object({
id: z.number().int(),
amountCents: money,
fixedPlanId: z.number().int().nullable()
}).passthrough()),
variableAllocations: z.array(z.object({
id: z.number().int(),
amountCents: money,
variableCategoryId: z.number().int().nullable()
}).passthrough()),
remainingUnallocatedCents: money
}).passthrough();
export const TransactionsList = z.object({
items: z.array(z.object({
id: z.number().int(),
kind: z.enum(["variable_spend", "fixed_payment"]).or(z.string()),
amountCents: z.number().int().nonnegative(),
occurredAt: z.string()
}).passthrough()),
page: z.number().int().nonnegative(),
limit: z.number().int().positive(),
total: z.number().int().nonnegative()
}).passthrough();
export type TransactionsListT = z.infer<typeof TransactionsList>;

View File

@@ -0,0 +1,20 @@
import { request } from "./client";
import { TransactionsList, type TransactionsListT } from "./schemas";
export type TxQuery = {
from?: string; // YYYY-MM-DD
to?: string; // YYYY-MM-DD
kind?: "variable_spend" | "fixed_payment";
q?: string;
page?: number; // 1-based
limit?: number; // default 20
};
export async function listTransactions(params: TxQuery): Promise<TransactionsListT> {
const u = new URL("/api/transactions", location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") u.searchParams.set(k, String(v));
}
const data = await request<unknown>(u.pathname + "?" + u.searchParams.toString());
return TransactionsList.parse(data);
}