added api logic, vitest, minimal testing ui
This commit is contained in:
30
web/src/api/categories.ts
Normal file
30
web/src/api/categories.ts
Normal 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
30
web/src/api/client.ts
Normal 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
27
web/src/api/fixedPlans.ts
Normal 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
65
web/src/api/http.ts
Normal 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
67
web/src/api/schemas.ts
Normal 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>;
|
||||
20
web/src/api/transactions.ts
Normal file
20
web/src/api/transactions.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user