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

2
web/.env.development Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8080/api
VITE_APP_NAME=SkyMoney

24
web/.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?

73
web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
web/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="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5859
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
web/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest --run",
"test:watch": "vitest"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.17",
"@tanstack/react-query": "^5.90.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.9.6",
"recharts": "^3.4.1",
"tailwindcss": "^4.1.17",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tanstack/react-query-devtools": "^5.90.2",
"@types/node": "^20.12.12",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.3",
"vite": "^7.2.2",
"vitest": "^2.1.3"
}
}

42
web/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

63
web/src/App.tsx Normal file
View File

@@ -0,0 +1,63 @@
import { Link, NavLink, Route, Routes } from "react-router-dom";
import DashboardPage from "./pages/DashboardPage";
import IncomePage from "./pages/IncomePage";
import TransactionsPage from "./pages/TransactionsPage";
export default function App() {
return (
<div className="min-h-screen bg-[--color-bg] text-[--color-fg]">
<TopNav />
<main className="container">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/income" element={<IncomePage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<footer className="container py-8 text-center text-sm muted">
SkyMoney {new Date().getFullYear()}
</footer>
</div>
);
}
function TopNav() {
return (
<header className="border-b border-[--color-ink] bg-[--color-panel]">
<div className="container h-14 flex items-center gap-4">
<Link to="/" className="font-bold">
SkyMoney
</Link>
<Nav to="/">Dashboard</Nav>
<Nav to="/income">Income</Nav>
<Nav to="/transactions">Transactions</Nav>
<div className="ml-auto text-xs muted">demo user</div>
</div>
</header>
);
}
function Nav({ to, children }: { to: string; children: React.ReactNode }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
"px-3 py-1 rounded-xl hover:bg-[--color-ink]/60 " +
(isActive ? "bg-[--color-ink]" : "")
}
end
>
{children}
</NavLink>
);
}
function NotFound() {
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-2">404</h1>
<p className="muted">This page got lost in the budget cuts.</p>
</div>
);
}

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

View File

@@ -0,0 +1,31 @@
import React from "react";
export default function CurrencyInput({
value,
onValue,
placeholder = "0.00",
}: {
value: string;
onValue: (v: string) => void;
placeholder?: string;
}) {
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value.replace(/[^0-9.]/g, "");
// Keep only first dot, max 2 decimals
const parts = raw.split(".");
const cleaned =
parts.length === 1
? parts[0]
: `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`;
onValue(cleaned);
}
return (
<input
className="input"
inputMode="decimal"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
);
}

View File

@@ -0,0 +1,31 @@
import React from "react";
export default function Pagination({
page,
limit,
total,
onPage,
}: {
page: number;
limit: number;
total: number;
onPage: (p: number) => void;
}) {
const pages = Math.max(1, Math.ceil(total / Math.max(1, limit)));
const prev = () => onPage(Math.max(1, page - 1));
const next = () => onPage(Math.min(pages, page + 1));
return (
<div className="row items-center mt-3">
<button className="btn" onClick={prev} disabled={page <= 1}>
Prev
</button>
<div className="mx-3 text-sm">
Page <strong>{page}</strong> of <strong>{pages}</strong> {total} total
</div>
<button className="btn" onClick={next} disabled={page >= pages}>
Next
</button>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import React from "react";
import { useDashboard } from "../hooks/useDashboard";
export default function PercentGuard() {
const dash = useDashboard();
const total = dash.data?.percentTotal ?? 0;
if (dash.isLoading) return null;
if (total === 100) return null;
return (
<div className="toast-err">
Variable category percents must sum to <strong>100%</strong> (currently {total}%).
Adjust them before recording income.
</div>
);
}

View File

@@ -0,0 +1,19 @@
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`animate-pulse rounded-[--radius-xl] bg-[color-mix(in_oklab,var(--color-ink) 70%,transparent)] ${className}`} />;
}
export function KPISkeleton() {
return <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>;
}
export function ChartSkeleton({ tall=false }: { tall?: boolean }) {
return <Skeleton className={tall ? "h-80" : "h-64"} />;
}
export function TableSkeleton() {
return <div className="card">
<div className="section-title">Loading</div>
<Skeleton className="h-40" />
</div>;
}

View File

@@ -0,0 +1,39 @@
import React, { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react";
type Toast = { id: string; kind: "ok" | "err"; message: string };
type Ctx = { push: (kind: Toast["kind"], message: string) => void };
const ToastCtx = createContext<Ctx>({ push: () => {} });
export function ToastProvider({ children }: PropsWithChildren<{}>) {
const [toasts, setToasts] = useState<Toast[]>([]);
const push = useCallback((kind: Toast["kind"], message: string) => {
const id = Math.random().toString(36).slice(2);
setToasts((t) => [...t, { id, kind, message }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3500);
}, []);
return (
<ToastCtx.Provider value={{ push }}>
{children}
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 space-y-2 z-50">
{toasts.map((t) => (
<div
key={t.id}
className={
"px-4 py-2 rounded-xl shadow " +
(t.kind === "ok" ? "bg-green-600 text-white" : "bg-red-600 text-white")
}
>
{t.message}
</div>
))}
</div>
</ToastCtx.Provider>
);
}
export function useToast() {
return useContext(ToastCtx);
}

View File

@@ -0,0 +1,25 @@
import { setUserId, getUserId } from "../api/client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
export default function UserSwitcher() {
const qc = useQueryClient();
const [val, setVal] = useState(getUserId());
const apply = () => {
setUserId(val);
qc.invalidateQueries(); // why: reload all data for new tenant
};
return (
<div className="row">
<input
className="input w-20"
type="number"
min={1}
value={val}
onChange={(e) => setVal(e.target.value)}
title="Dev User Id"
/>
<button className="btn" type="button" onClick={apply}>Use</button>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
export type FixedItem = { name: string; funded: number; remaining: number };
export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
if (!data.length) {
return (
<div className="card">
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Fixed Plan Funding</h3>
<div className="muted text-sm">No fixed plans yet.</div>
</div>
);
}
return (
<div className="card">
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Fixed Plan Funding</h3>
<div className="chart-lg">
<ResponsiveContainer>
<BarChart data={data} stackOffset="expand">
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
<XAxis dataKey="name" stroke="#94a3b8" />
<YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} stroke="#94a3b8" />
<Tooltip formatter={(v: number) => `${Math.round(Number(v) * 100)}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
<Legend />
<Bar dataKey="funded" stackId="a" fill="#165F46" name="Funded" />
<Bar dataKey="remaining" stackId="a" fill="#374151" name="Remaining" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
// web/src/components/charts/VariableAllocationDonut.tsx
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
export type VariableSlice = { name: string; value: number; isSavings: boolean };
export default function VariableAllocationDonut({ data }: { data: VariableSlice[] }) {
const total = data.reduce((s, d) => s + d.value, 0);
if (!data.length || total === 0) {
return (
<div className="card">
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Variable Allocation</h3>
<div className="muted text-sm">No categories configured yet.</div>
</div>
);
}
const fillFor = (s: boolean) => (s ? "#165F46" : "#374151");
return (
<div className="card">
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Variable Allocation</h3>
<div className="chart-md">
<ResponsiveContainer>
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={70} outerRadius={100} stroke="#0B1020" strokeWidth={2}>
{data.map((d, i) => <Cell key={i} fill={fillFor(d.isSavings)} />)}
</Pie>
<Tooltip formatter={(v: number) => `${v}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
<Legend verticalAlign="bottom" height={24} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
);
}

36
web/src/components/ui.tsx Normal file
View File

@@ -0,0 +1,36 @@
import React, { type PropsWithChildren } from "react";
import { fmtMoney } from "../utils/money";
export function Money({ cents }: { cents: number }) {
return <span className="font-mono">{fmtMoney(cents)}</span>;
}
export function Field({
label,
children,
}: PropsWithChildren<{ label: string }>) {
return (
<label className="stack">
<span className="text-sm muted">{label}</span>
{children}
</label>
);
}
export function Button({
children,
disabled,
onClick,
type = "submit",
}: PropsWithChildren<{ disabled?: boolean; onClick?: () => void; type?: "button" | "submit" }>) {
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
className="btn"
>
{children}
</button>
);
}

View File

@@ -0,0 +1,78 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { categoriesApi, type NewCategory, type UpdateCategory } from "../api/categories";
const DASHBOARD_KEY = ["dashboard"];
export function useCategories() {
const qc = useQueryClient();
const snap = qc.getQueryData<any>(DASHBOARD_KEY);
return snap?.variableCategories ?? [];
}
export function useCreateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: NewCategory) => categoriesApi.create(body),
onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
if (prev) {
const optimistic = {
...prev,
variableCategories: [
...prev.variableCategories,
{ id: -Math.floor(Math.random() * 1e9), balanceCents: 0, ...vars }
]
};
qc.setQueryData(DASHBOARD_KEY, optimistic);
}
return { prev };
},
onError: (_err, _vars, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
});
}
export function useUpdateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdateCategory }) => categoriesApi.update(id, body),
onMutate: async ({ id, body }) => {
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
if (prev) {
const optimistic = {
...prev,
variableCategories: prev.variableCategories.map((c: any) =>
c.id === id ? { ...c, ...body } : c
)
};
qc.setQueryData(DASHBOARD_KEY, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
});
}
export function useDeleteCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => categoriesApi.delete(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
if (prev) {
const optimistic = {
...prev,
variableCategories: prev.variableCategories.filter((c: any) => c.id !== id)
};
qc.setQueryData(DASHBOARD_KEY, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
});
}

View File

@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../api/http";
export type VariableCategory = {
id: string;
name: string;
percent: number;
priority: number;
isSavings: boolean;
balanceCents?: number;
};
export type FixedPlan = {
id: string;
name: string;
priority: number;
totalCents?: number;
fundedCents?: number;
dueOn: string;
};
export type Tx = {
id: string;
kind: "variable_spend" | "fixed_payment";
amountCents: number;
occurredAt: string;
};
export type DashboardResponse = {
totals: {
incomeCents: number;
variableBalanceCents: number;
fixedRemainingCents: number;
};
percentTotal: number;
variableCategories: VariableCategory[];
fixedPlans: FixedPlan[];
recentTransactions: Tx[];
};
export function useDashboard() {
return useQuery({
queryKey: ["dashboard"],
queryFn: () => apiGet<DashboardResponse>("/dashboard"),
staleTime: 10_000,
});
}

View File

@@ -0,0 +1,86 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { fixedPlansApi, type NewPlan, type UpdatePlan } from "../api/fixedPlans";
const DASH = ["dashboard"];
export function useCreatePlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: NewPlan) => fixedPlansApi.create(body),
onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: DASH });
const prev = qc.getQueryData<any>(DASH);
if (prev) {
const optimistic = {
...prev,
fixedPlans: [
...prev.fixedPlans,
{
id: -Math.floor(Math.random() * 1e9),
fundedCents: Math.min(vars.fundedCents ?? 0, vars.totalCents),
...vars,
},
],
};
qc.setQueryData(DASH, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
});
}
export function useUpdatePlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdatePlan }) =>
fixedPlansApi.update(id, body),
onMutate: async ({ id, body }) => {
await qc.cancelQueries({ queryKey: DASH });
const prev = qc.getQueryData<any>(DASH);
if (prev) {
const optimistic = {
...prev,
fixedPlans: prev.fixedPlans.map((p: any) =>
p.id === id
? {
...p,
...body,
fundedCents: Math.min(
"fundedCents" in (body || {}) ? (body as any).fundedCents ?? p.fundedCents : p.fundedCents,
"totalCents" in (body || {}) ? (body as any).totalCents ?? p.totalCents : p.totalCents
),
}
: p
),
};
qc.setQueryData(DASH, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
});
}
export function useDeletePlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => fixedPlansApi.delete(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: DASH });
const prev = qc.getQueryData<any>(DASH);
if (prev) {
const optimistic = {
...prev,
fixedPlans: prev.fixedPlans.filter((p: any) => p.id !== id),
};
qc.setQueryData(DASH, optimistic);
}
return { prev };
},
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
});
}

View File

@@ -0,0 +1,15 @@
import { useMutation } from "@tanstack/react-query";
import { apiPost } from "../api/http";
export type IncomeResult = {
fixedAllocations: Array<{ fixedPlanId: string; amountCents: number }>;
variableAllocations: Array<{ variableCategoryId: string; amountCents: number }>;
remainingUnallocatedCents: number;
};
export function useCreateIncome() {
return useMutation({
mutationFn: (body: { amountCents: number }) =>
apiPost<IncomeResult>("/income", body),
});
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { apiPost } from "../api/http";
export type PreviewAlloc = { id: string; name: string; amountCents: number };
export type IncomePreview = {
fixed: PreviewAlloc[];
variable: PreviewAlloc[];
unallocatedCents: number;
};
export function useIncomePreview(amountCents: number) {
return useQuery({
queryKey: ["income-preview", amountCents],
enabled: amountCents > 0,
queryFn: () =>
apiPost<IncomePreview>("/income/preview", { amountCents }),
});
}

View File

@@ -0,0 +1,28 @@
// web/src/hooks/useTransaction.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { api } from "../api/client";
const Tx = z.object({
kind: z.enum(["variable_spend", "fixed_payment"]),
amountCents: z.number().int().positive(),
occurredAtISO: z.string().datetime(),
variableCategoryId: z.number().int().optional(),
fixedPlanId: z.number().int().optional()
}).superRefine((v, ctx) => {
const isVar = v.kind === "variable_spend";
if (isVar && !v.variableCategoryId) ctx.addIssue({ code: "custom", message: "variableCategoryId required" });
if (!isVar && !v.fixedPlanId) ctx.addIssue({ code: "custom", message: "fixedPlanId required" });
});
export type TxInput = z.infer<typeof Tx>;
export function useCreateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: TxInput) => api.post("/transactions", Tx.parse(input)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["transactions"] }); // ensure list refreshes if open
}
});
}

View File

@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../api/http";
export type TxItem = {
id: string;
kind: "variable_spend" | "fixed_payment";
amountCents: number;
occurredAt: string;
};
export type TxListResponse = {
items: TxItem[];
page: number;
limit: number;
total: number;
};
export type TxQueryParams = {
page: number;
limit: number;
q?: string;
from?: string;
to?: string;
kind?: "variable_spend" | "fixed_payment";
};
export function useTransactionsQuery(params: TxQueryParams) {
return useQuery({
queryKey: ["transactions", params],
queryFn: () => apiGet<TxListResponse>("/transactions", params),
placeholderData: (previousData) => previousData,
});
}

128
web/src/index.css Normal file
View File

@@ -0,0 +1,128 @@
@import "tailwindcss";
@theme {
/* Colors */
--color-midnight: #0B1020; /* app bg */
--color-ink: #111827; /* surfaces/lines */
--color-pine: #165F46; /* primary: discipline/growth */
--color-sage: #B7CAB6; /* muted text */
--color-sand: #E7E3D7; /* body text */
--color-amber: #E0B04E; /* positive */
--color-rose: #CC4B4B; /* alerts */
--radius-lg: 0.75rem;
--radius-xl: 0.9rem;
--radius-2xl: 1.2rem;
--shadow-soft: 0 6px 24px rgba(0,0,0,.25);
--container-w: 72rem;
}
html, body, #root { height: 100%; }
body {
background: var(--color-midnight);
color: var(--color-sand);
font-family: var(--font-sans);
}
/* Accessible focus ring */
:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-pine) 80%, white 20%); outline-offset: 2px; }
/* Motion sensitivity */
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
}
/* Utility tweaks */
.mono { font-family: var(--font-mono); }
.muted { color: var(--color-sage); }
/* ==== App Shell ==== */
.app { @apply min-h-full grid grid-rows-[auto,1fr]; }
/* Top nav */
.nav {
@apply sticky top-0 z-10 backdrop-blur border-b;
background: color-mix(in oklab, var(--color-ink) 85%, transparent);
border-color: var(--color-ink);
}
.container { @apply mx-auto p-4 md:p-6; max-width: var(--container-w); }
/* Links in the header */
.link { @apply px-3 py-2 rounded-[--radius-xl] transition; }
.link:hover { background: var(--color-ink); }
.link-active { background: var(--color-ink); }
/* ==== Cards / Sections ==== */
.card {
@apply p-4 border rounded-[--radius-2xl];
background: var(--color-ink);
border-color: var(--color-ink);
box-shadow: var(--shadow-soft);
}
.section-title { @apply text-lg font-semibold mb-3; }
/* KPI tile */
.kpi { @apply flex flex-col gap-1; } /* no 'card' here */
.kpi h3 { @apply text-sm; color: var(--color-sage); }
.kpi .val { @apply text-2xl font-semibold; }
/* ==== Buttons ==== */
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 font-semibold border rounded-[--radius-xl] active:scale-[.99];
background: var(--color-pine);
color: var(--color-sand);
border-color: var(--color-ink);
}
.btn:disabled { @apply opacity-60 cursor-not-allowed; }
/* ==== Inputs ==== */
.input, select, textarea {
@apply w-full px-3 py-2 rounded-[--radius-xl] border outline-none;
background: var(--color-midnight);
border-color: var(--color-ink);
}
label.field { @apply grid gap-1 mb-3; }
label.field > span { @apply text-sm; color: var(--color-sage); }
/* ==== Table ==== */
.table { @apply w-full border-separate; border-spacing: 0 0.5rem; }
.table thead th { @apply text-left text-sm pb-2; color: var(--color-sage); }
.table tbody tr { background: var(--color-ink); }
.table td, .table th { @apply px-3 py-2; }
.table tbody tr > td:first-child { @apply rounded-l-[--radius-xl]; }
.table tbody tr > td:last-child { @apply rounded-r-[--radius-xl]; }
/* ==== Badges / Chips ==== */
.badge {
@apply inline-flex items-center px-2 py-0.5 text-xs rounded-full border;
background: var(--color-ink);
border-color: var(--color-ink);
}
/* ==== Simple Toasts ==== */
.toast-ok {
@apply rounded-[--radius-xl] px-3 py-2 border;
background: #0a2917; /* dark green */
color: #bbf7d0;
border-color: var(--color-pine);
}
.toast-err {
@apply rounded-[--radius-xl] px-3 py-2 border;
background: #3f1515;
color: #fecaca;
border-color: #7f1d1d;
}
/* ==== Layout helpers ==== */
.grid-auto { @apply grid gap-4 sm:grid-cols-2 lg:grid-cols-3; }
.stack { @apply flex flex-col gap-3; }
.row { @apply flex items-center gap-3; }
/* ==== Chart wrappers (consistent sizing) ==== */
.chart-md { @apply h-64; }
.chart-lg { @apply h-72; }

30
web/src/main.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ToastProvider } from "./components/Toast";
import App from "./App";
import "./styles.css";
const client = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={client}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,148 @@
import { useEffect, useState } from "react";
import { apiGet } from "../api/http";
import { fmtMoney } from "../utils/money";
type VariableCategory = {
id: string; name: string; percent: number; priority: number;
isSavings: boolean; balanceCents?: number;
};
type FixedPlan = {
id: string; name: string; priority: number;
totalCents?: number; fundedCents?: number; dueOn: string;
};
type Tx = { id: string; kind: "variable_spend"|"fixed_payment"; amountCents: number; occurredAt: string };
type DashboardResponse = {
totals: {
incomeCents: number;
variableBalanceCents: number;
fixedRemainingCents: number;
};
percentTotal: number;
variableCategories: VariableCategory[];
fixedPlans: FixedPlan[];
recentTransactions: Tx[];
};
export default function DashboardPage() {
const [data, setData] = useState<DashboardResponse | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
setErr(null);
try {
const d = await apiGet<DashboardResponse>("/dashboard");
setData(d);
} catch (e: any) {
setErr(e.message || "Failed to load");
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
if (loading) return <div className="p-6">Loading dashboard</div>;
if (err) return (
<div className="p-6 text-red-600">
{err} <button className="ml-2 underline" onClick={load}>retry</button>
</div>
);
if (!data) return null;
return (
<div className="p-6 space-y-8">
<h1 className="text-2xl font-bold">Dashboard</h1>
<section className="grid gap-4 md:grid-cols-3">
<Card label="Total Income" value={fmtMoney(data.totals.incomeCents)} />
<Card label="Variable Balance" value={fmtMoney(data.totals.variableBalanceCents)} />
<Card label="Fixed Remaining" value={fmtMoney(data.totals.fixedRemainingCents)} />
</section>
<section className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<h2 className="font-semibold">Variable Categories (sum {data.percentTotal}%)</h2>
<div className="border rounded-md divide-y">
{data.variableCategories.map(c => (
<div key={c.id} className="p-3 flex items-center justify-between">
<div>
<div className="font-medium">{c.name}</div>
<div className="text-sm opacity-70">
{c.isSavings ? "Savings • " : ""}Priority {c.priority}
</div>
</div>
<div className="text-right">
<div className="font-mono">{fmtMoney(c.balanceCents ?? 0)}</div>
<div className="text-sm opacity-70">{c.percent}%</div>
</div>
</div>
))}
</div>
</div>
<div className="space-y-2">
<h2 className="font-semibold">Fixed Plans</h2>
<div className="border rounded-md divide-y">
{data.fixedPlans.map(p => {
const total = p.totalCents ?? 0;
const funded = p.fundedCents ?? 0;
const remaining = Math.max(total - funded, 0);
return (
<div key={p.id} className="p-3 flex items-center justify-between">
<div>
<div className="font-medium">{p.name}</div>
<div className="text-sm opacity-70">
Due {new Date(p.dueOn).toLocaleDateString()} Priority {p.priority}
</div>
</div>
<div className="text-right">
<div className="font-mono">{fmtMoney(remaining)}</div>
<div className="text-sm opacity-70">remaining</div>
</div>
</div>
);
})}
</div>
</div>
</section>
<section className="space-y-2">
<h2 className="font-semibold">Recent Transactions</h2>
<div className="border rounded-md overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left p-2">Date</th>
<th className="text-left p-2">Kind</th>
<th className="text-right p-2">Amount</th>
</tr>
</thead>
<tbody>
{data.recentTransactions.map(tx => (
<tr key={tx.id} className="border-t">
<td className="p-2">{new Date(tx.occurredAt).toLocaleString()}</td>
<td className="p-2">{tx.kind.replace("_", " ")}</td>
<td className="p-2 text-right font-mono">{fmtMoney(tx.amountCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
}
function Card({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border p-4 shadow-sm">
<div className="text-sm opacity-70">{label}</div>
<div className="text-xl font-bold mt-1">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
export default function HealthPage() {
const app = useQuery({ queryKey: ["health"], queryFn: () => api.get<{ok:true}>("/health") });
const db = useQuery({ queryKey: ["health","db"], queryFn: () => api.get<{ok:true; nowISO:string; latencyMs:number}>("/health/db") });
return (
<div className="card max-w-lg">
<h2 className="section-title">Health</h2>
<ul className="stack">
<li>API: {app.isLoading ? "…" : app.data?.ok ? "OK" : "Down"}</li>
<li>DB: {db.isLoading ? "…" : db.data?.ok ? `OK (${db.data.latencyMs} ms)` : "Down"}</li>
<li>Server Time: {db.data?.nowISO ? new Date(db.data.nowISO).toLocaleString() : "…"}</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,223 @@
import { useMemo, useState, type FormEvent } from "react";
import { useCreateIncome } from "../hooks/useIncome";
import { useDashboard } from "../hooks/useDashboard";
import { Money, Field, Button } from "../components/ui";
import CurrencyInput from "../components/CurrencyInput";
import { previewAllocation } from "../utils/allocatorPreview";
import PercentGuard from "../components/PercentGuard";
import { useToast } from "../components/Toast";
import { useIncomePreview } from "../hooks/useIncomePreview";
function dollarsToCents(input: string): number {
const n = Number.parseFloat(input || "0");
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
}
type Alloc = { id: number | string; amountCents: number; name: string };
export default function IncomePage() {
const [amountStr, setAmountStr] = useState("");
const { push } = useToast();
const m = useCreateIncome();
const dash = useDashboard();
const cents = dollarsToCents(amountStr);
const canSubmit = (dash.data?.percentTotal ?? 0) === 100;
// Server preview (preferred) with client fallback
const srvPreview = useIncomePreview(cents);
const preview = useMemo(() => {
if (!dash.data || cents <= 0) return null;
if (srvPreview.data) return srvPreview.data;
// fallback: local simulation
return previewAllocation(cents, dash.data.fixedPlans, dash.data.variableCategories);
}, [cents, dash.data, srvPreview.data]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (cents <= 0 || !canSubmit) return;
m.mutate(
{ amountCents: cents },
{
onSuccess: (res) => {
const fixed = (res.fixedAllocations ?? []).reduce(
(s: number, a: any) => s + (a.amountCents ?? 0),
0
);
const variable = (res.variableAllocations ?? []).reduce(
(s: number, a: any) => s + (a.amountCents ?? 0),
0
);
const unalloc = res.remainingUnallocatedCents ?? 0;
push(
"ok",
`Allocated: Fixed ${(fixed / 100).toFixed(2)} + Variable ${(variable / 100).toFixed(
2
)}. Unallocated ${(unalloc / 100).toFixed(2)}.`
);
setAmountStr("");
},
onError: (err: any) => push("err", err?.message ?? "Income failed"),
}
);
};
const variableAllocations: Alloc[] = useMemo(() => {
if (!m.data) return [];
const nameById = new Map<string | number, string>(
(dash.data?.variableCategories ?? []).map((c) => [c.id as string | number, c.name] as const)
);
const grouped = new Map<string | number, number>();
for (const a of m.data.variableAllocations ?? []) {
const id = (a as any).variableCategoryId ?? (a as any).id ?? -1;
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
}
return [...grouped.entries()]
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Category #${id}` }))
.sort((a, b) => b.amountCents - a.amountCents);
}, [m.data, dash.data]);
const fixedAllocations: Alloc[] = useMemo(() => {
if (!m.data) return [];
const nameById = new Map<string | number, string>(
(dash.data?.fixedPlans ?? []).map((p) => [p.id as string | number, p.name] as const)
);
const grouped = new Map<string | number, number>();
for (const a of m.data.fixedAllocations ?? []) {
const id = (a as any).fixedPlanId ?? (a as any).id ?? -1;
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
}
return [...grouped.entries()]
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Plan #${id}` }))
.sort((a, b) => b.amountCents - a.amountCents);
}, [m.data, dash.data]);
const hasResult = !!m.data;
return (
<div className="stack max-w-lg">
<PercentGuard />
<form onSubmit={submit} className="card">
<h2 className="section-title">Record Income</h2>
<Field label="Amount (USD)">
<CurrencyInput value={amountStr} onValue={setAmountStr} />
</Field>
<Button disabled={m.isPending || cents <= 0 || !canSubmit}>
{m.isPending ? "Allocating…" : canSubmit ? "Submit" : "Fix percents to 100%"}
</Button>
{/* Live Preview */}
{!hasResult && preview && (
<div className="mt-4 stack">
<div className="row">
<h3 className="text-sm muted">Preview (not yet applied)</h3>
<span className="ml-auto text-sm">
Unallocated: <Money cents={preview.unallocatedCents} />
</span>
</div>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h4 className="text-sm muted">Fixed Plans</h4>
<span className="ml-auto font-semibold">
<Money cents={preview.fixed.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
{preview.fixed.length === 0 ? (
<div className="muted text-sm">No fixed allocations.</div>
) : (
<ul className="list-disc pl-5 space-y-1">
{preview.fixed.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
)}
</section>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h4 className="text-sm muted">Variable Categories</h4>
<span className="ml-auto font-semibold">
<Money cents={preview.variable.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
{preview.variable.length === 0 ? (
<div className="muted text-sm">No variable allocations.</div>
) : (
<ul className="list-disc pl-5 space-y-1">
{preview.variable.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
)}
{/* Actual Result */}
{m.error && <div className="toast-err mt-3"> {(m.error as any).message}</div>}
{hasResult && (
<div className="mt-4 stack">
<div className="row">
<span className="muted text-sm">Unallocated</span>
<span className="ml-auto">
<Money cents={m.data?.remainingUnallocatedCents ?? 0} />
</span>
</div>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h3 className="text-sm muted">Fixed Plans (Applied)</h3>
<span className="ml-auto font-semibold">
<Money cents={fixedAllocations.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{fixedAllocations.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
</section>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h3 className="text-sm muted">Variable Categories (Applied)</h3>
<span className="ml-auto font-semibold">
<Money cents={variableAllocations.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{variableAllocations.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
</section>
</div>
)}
</form>
</div>
);
}

168
web/src/pages/SpendPage.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { useMemo, useState, type FormEvent, type ChangeEvent } from "react";
import { useDashboard } from "../hooks/useDashboard";
import { useCreateTransaction } from "../hooks/useTransactions";
import { Money, Field, Button } from "../components/ui";
import CurrencyInput from "../components/CurrencyInput";
import { useToast } from "../components/Toast";
import { nowLocalISOStringMinute } from "../utils/format";
type Kind = "variable_spend" | "fixed_payment";
function dollarsToCents(input: string): number {
const n = Number.parseFloat(input || "0");
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
}
export default function SpendPage() {
const dash = useDashboard();
const m = useCreateTransaction();
const { push } = useToast();
const [kind, setKind] = useState<Kind>("variable_spend");
const [amountStr, setAmountStr] = useState("");
const [occurredAt, setOccurredAt] = useState(nowLocalISOStringMinute());
const [variableCategoryId, setVariableCategoryId] = useState<string>("");
const [fixedPlanId, setFixedPlanId] = useState<string>("");
const amountCents = dollarsToCents(amountStr);
// Optional UX lock: block variable spend if chosen category has 0 balance.
const selectedCategory = useMemo(() => {
if (!dash.data) return null;
return dash.data.variableCategories.find(c => String(c.id) === variableCategoryId) ?? null;
}, [dash.data, variableCategoryId]);
const disableIfZeroBalance = true; // flip to false to allow negatives
const categoryBlocked =
kind === "variable_spend" &&
!!selectedCategory &&
disableIfZeroBalance &&
(selectedCategory.balanceCents ?? 0) <= 0;
const canSubmit = useMemo(() => {
if (!dash.data) return false;
if (amountCents <= 0) return false;
if (kind === "variable_spend") return !!variableCategoryId && !categoryBlocked;
return !!fixedPlanId; // fixed_payment
}, [dash.data, amountCents, kind, variableCategoryId, fixedPlanId, categoryBlocked]);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
const payload = {
kind,
amountCents,
occurredAtISO: new Date(occurredAt).toISOString(),
variableCategoryId: kind === "variable_spend" ? Number(variableCategoryId) : undefined,
fixedPlanId: kind === "fixed_payment" ? Number(fixedPlanId) : undefined,
} as any;
m.mutate(payload, {
onSuccess: () => {
push("ok", kind === "variable_spend" ? "Recorded spend." : "Recorded payment.");
setAmountStr("");
// Keep date defaulting to “now” for quick entry
setOccurredAt(nowLocalISOStringMinute());
},
onError: (err: any) => push("err", err?.message ?? "Failed to record"),
});
};
const cats = dash.data?.variableCategories ?? [];
const plans = dash.data?.fixedPlans ?? [];
return (
<div className="grid gap-4 max-w-xl">
<form onSubmit={onSubmit} className="card stack">
<h2 className="section-title">Spend / Pay</h2>
{/* Kind toggle */}
<div className="row gap-2">
<label className="row">
<input
type="radio"
checked={kind === "variable_spend"}
onChange={() => setKind("variable_spend")}
/>
<span className="text-sm">Variable Spend</span>
</label>
<label className="row">
<input
type="radio"
checked={kind === "fixed_payment"}
onChange={() => setKind("fixed_payment")}
/>
<span className="text-sm">Fixed Payment</span>
</label>
</div>
{/* Pick target */}
{kind === "variable_spend" ? (
<Field label="Category">
<select
className="input"
value={variableCategoryId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setVariableCategoryId(e.target.value)}
>
<option value="">Select a category</option>
{cats
.slice()
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
.map(c => (
<option key={c.id} value={String(c.id)}>
{c.name} <Money cents={c.balanceCents} />
</option>
))}
</select>
</Field>
) : (
<Field label="Fixed Plan">
<select
className="input"
value={fixedPlanId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setFixedPlanId(e.target.value)}
>
<option value="">Select a plan</option>
{plans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => (
<option key={p.id} value={String(p.id)}>
{p.name} Funded <Money cents={p.fundedCents} /> / <Money cents={p.totalCents} />
</option>
))}
</select>
</Field>
)}
{/* Amount + Date */}
<Field label="Amount (USD)">
<CurrencyInput value={amountStr} onValue={setAmountStr} />
</Field>
<Field label="When">
<input
className="input"
type="datetime-local"
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
/>
</Field>
{/* Guard + submit */}
{categoryBlocked && (
<div className="toast-err">
Selected category has no available balance. Add income or pick another category.
</div>
)}
<Button disabled={m.isPending || !canSubmit}>
{m.isPending ? "Saving…" : "Submit"}
</Button>
{m.error && <div className="toast-err"> {(m.error as any).message}</div>}
</form>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
import { Money } from "../components/ui";
import Pagination from "../components/Pagination";
type Kind = "all" | "variable_spend" | "fixed_payment";
function isoDateOnly(d: Date) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${da}`;
}
export default function TransactionsPage() {
const today = isoDateOnly(new Date());
const [sp, setSp] = useSearchParams();
// init from URL
const initKind = (sp.get("kind") as Kind) || "all";
const initQ = sp.get("q") || "";
const initFrom = sp.get("from") || "";
const initTo = sp.get("to") || today;
const initPage = Math.max(1, Number(sp.get("page") || 1));
const [kind, setKind] = useState<Kind>(initKind);
const [qRaw, setQRaw] = useState(initQ);
const [q, setQ] = useState(initQ.trim());
const [from, setFrom] = useState(initFrom);
const [to, setTo] = useState(initTo);
const [page, setPage] = useState(initPage);
const limit = 20;
// debounce search
useEffect(() => {
const id = setTimeout(() => {
setPage(1);
setQ(qRaw.trim());
}, 250);
return () => clearTimeout(id);
}, [qRaw]);
// write to URL on change
useEffect(() => {
const next = new URLSearchParams();
if (kind !== "all") next.set("kind", kind);
if (q) next.set("q", q);
if (from) next.set("from", from);
if (to) next.set("to", to);
if (page !== 1) next.set("page", String(page));
setSp(next, { replace: true });
}, [kind, q, from, to, page, setSp]);
const params = useMemo(
() => ({
page,
limit,
q: q || undefined,
from: from || undefined,
to: to || undefined,
kind: kind === "all" ? undefined : kind,
}),
[page, limit, q, from, to, kind]
);
const { data, isLoading, error, refetch, isFetching } = useTransactionsQuery(params);
const clear = () => {
setKind("all");
setQRaw("");
setFrom("");
setTo(today);
setPage(1);
};
const rows = data?.items ?? [];
const totalAmount = rows.reduce((s, r) => s + (r.amountCents ?? 0), 0);
return (
<div className="grid gap-4">
<section className="card">
<h2 className="section-title">Transactions</h2>
{/* Filters */}
<div className="row gap-2 flex-wrap mb-3">
<select
className="input w-44"
value={kind}
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
setKind(e.target.value as Kind);
setPage(1);
}}
>
<option value="all">All Types</option>
<option value="variable_spend">Variable Spend</option>
<option value="fixed_payment">Fixed Payment</option>
</select>
<input
className="input w-56"
placeholder="Search…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
<input
className="input w-40"
type="date"
value={from}
onChange={(e) => {
setFrom(e.target.value);
setPage(1);
}}
/>
<input
className="input w-40"
type="date"
value={to}
onChange={(e) => {
setTo(e.target.value);
setPage(1);
}}
/>
<button type="button" className="btn" onClick={clear}>
Clear
</button>
<div className="badge ml-auto">
{isFetching ? "Refreshing…" : `Showing ${rows.length}`}
</div>
</div>
{/* States */}
{isLoading && <div className="muted text-sm">Loading</div>}
{error && !isLoading && (
<div className="toast-err mb-3">
Couldnt load transactions.{" "}
<button className="btn ml-2" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
)}
{/* Table */}
{!isLoading && rows.length === 0 ? (
<div className="muted text-sm">No transactions match your filters.</div>
) : (
<>
<table className="table">
<thead>
<tr>
<th>Type</th>
<th>Amount</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">{t.kind}</td>
<td className="px-3 py-2">
<Money cents={t.amountCents} />
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
{new Date(t.occurredAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
<div className="row mt-2">
<div className="muted text-sm">Page total</div>
<div className="ml-auto font-semibold">
<Money cents={totalAmount} />
</div>
</div>
{data && (
<Pagination page={data.page} limit={data.limit} total={data.total} onPage={setPage} />
)}
</>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import { useDashboard } from "../../hooks/useDashboard";
import { Money } from "../../components/ui";
import SettingsNav from "./_SettingsNav";
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from "../../hooks/useCategories";
import { useToast } from "../../components/Toast";
type Row = { id: number; name: string; percent: number; priority: number; isSavings: boolean; balanceCents: number };
function SumBadge({ total }: { total: number }) {
const ok = total === 100;
return (
<div className={`badge ${ok ? "" : ""}`}>
Total: {total}%
</div>
);
}
export default function SettingsCategoriesPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const cats: Row[] = useCategories();
const createM = useCreateCategory();
const updateM = useUpdateCategory();
const deleteM = useDeleteCategory();
const { push } = useToast();
const total = useMemo(() => cats.reduce((s, c) => s + c.percent, 0), [cats]);
// Add form state
const [name, setName] = useState("");
const [percent, setPercent] = useState("");
const [priority, setPriority] = useState("");
const [isSavings, setIsSavings] = useState(false);
const addDisabled = !name || !percent || !priority || Number(percent) < 0 || Number(percent) > 100;
const onAdd = (e: FormEvent) => {
e.preventDefault();
const body = {
name: name.trim(),
percent: Math.max(0, Math.min(100, Math.floor(Number(percent) || 0))),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
isSavings
};
createM.mutate(body, {
onSuccess: () => {
push("ok", "Category created");
setName(""); setPercent(""); setPriority(""); setIsSavings(false);
},
onError: (err: any) => push("err", err?.message ?? "Create failed")
});
};
const onEdit = (id: number, patch: Partial<Row>) => {
updateM.mutate({ id, body: patch }, {
onError: (err: any) => push("err", err?.message ?? "Update failed")
});
};
const onDelete = (id: number) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Category deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed")
});
};
if (isLoading) return <div className="card max-w-2xl"><SettingsNav/><div className="muted">Loading</div></div>;
if (error || !data) {
return (
<div className="card max-w-2xl">
<SettingsNav/>
<p className="mb-3">Couldnt load categories.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
</div>
);
}
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-44" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="%"
type="number" min={0} max={100} value={percent} onChange={(e) => setPercent(e.target.value)} />
<input className="input w-28" placeholder="Priority"
type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<label className="row">
<input type="checkbox" checked={isSavings} onChange={(e) => setIsSavings(e.target.checked)} />
<span className="muted text-sm">Savings</span>
</label>
<button className="btn" disabled={addDisabled || createM.isPending}>Add</button>
<div className="ml-auto"><SumBadge total={total} /></div>
</form>
{cats.length === 0 ? (
<div className="muted text-sm">No categories yet.</div>
) : (
<table className="table">
<thead><tr><th>Name</th><th>%</th><th>Priority</th><th>Savings</th><th>Balance</th><th></th></tr></thead>
<tbody>
{cats
.slice()
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
.map(c => (
<tr key={c.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={c.name} onChange={(v) => onEdit(c.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={c.percent} min={0} max={100} onChange={(v) => onEdit(c.id, { percent: v })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={c.priority} min={0} onChange={(v) => onEdit(c.id, { priority: v })} />
</td>
<td className="px-3 py-2">
<InlineEditCheckbox checked={c.isSavings} onChange={(v) => onEdit(c.id, { isSavings: v })} />
</td>
<td className="px-3 py-2"><Money cents={c.balanceCents} /></td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(c.id)} disabled={deleteM.isPending}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Guard if total != 100 */}
{total !== 100 && (
<div className="toast-err mt-3">
Percents must sum to <strong>100%</strong> for allocations. Current total: {total}%.
</div>
)}
</section>
</div>
);
}
/* --- tiny inline editors --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [v, setV] = useState(value);
const [editing, setEditing] = useState(false);
const commit = () => { if (v !== value) onChange(v.trim()); setEditing(false); };
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
);
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number; }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
const commit = () => {
const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0)));
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
);
}
function InlineEditCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="row">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="muted text-sm">{checked ? "Yes" : "No"}</span>
</label>
);
}

View File

@@ -0,0 +1,218 @@
// web/src/pages/settings/PlansPage.tsx
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import { useDashboard } from "../../hooks/useDashboard";
import SettingsNav from "./_SettingsNav";
import { useCreatePlan, useUpdatePlan, useDeletePlan } from "../../hooks/useFixedPlans";
import { Money } from "../../components/ui";
import { useToast } from "../../components/Toast";
function isoDateLocal(d: Date = new Date()) {
const z = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
return z.toISOString().slice(0, 10);
}
function daysUntil(iso: string) {
const today = new Date(isoDateLocal());
const due = new Date(isoDateLocal(new Date(iso)));
const diffMs = due.getTime() - today.getTime();
return Math.round(diffMs / (24 * 60 * 60 * 1000));
}
function DueBadge({ dueISO }: { dueISO: string }) {
const d = daysUntil(dueISO);
if (d < 0) return <span className="badge" style={{ borderColor: "#7f1d1d" }}>Overdue</span>;
if (d <= 7) return <span className="badge">Due in {d}d</span>;
return <span className="badge" aria-hidden="true">On track</span>;
}
export default function SettingsPlansPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const createM = useCreatePlan();
const updateM = useUpdatePlan();
const deleteM = useDeletePlan();
const { push } = useToast();
// Add form state
const [name, setName] = useState("");
const [total, setTotal] = useState("");
const [funded, setFunded] = useState("");
const [priority, setPriority] = useState("");
const [due, setDue] = useState(isoDateLocal());
const totals = useMemo(() => {
if (!data) return { funded: 0, total: 0, remaining: 0 };
const funded = data.fixedPlans.reduce((s, p) => s + p.fundedCents, 0);
const total = data.fixedPlans.reduce((s, p) => s + p.totalCents, 0);
return { funded, total, remaining: Math.max(0, total - funded) };
}, [data]);
if (isLoading) return <div className="card max-w-3xl"><SettingsNav/><div className="muted">Loading</div></div>;
if (error || !data) {
return (
<div className="card max-w-3xl">
<SettingsNav/>
<p className="mb-3">Couldnt load fixed plans.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
</div>
);
}
const onAdd = (e: FormEvent) => {
e.preventDefault();
const totalCents = Math.max(0, Math.round((parseFloat(total || "0")) * 100));
const fundedCents = Math.max(0, Math.round((parseFloat(funded || "0")) * 100));
const body = {
name: name.trim(),
totalCents,
fundedCents: Math.min(fundedCents, totalCents),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
dueOn: new Date(due).toISOString(),
};
if (!body.name || totalCents <= 0) return;
createM.mutate(body, {
onSuccess: () => {
push("ok", "Plan created");
setName(""); setTotal(""); setFunded(""); setPriority(""); setDue(isoDateLocal());
},
onError: (err: any) => push("err", err?.message ?? "Create failed"),
});
};
const onEdit = (id: number, patch: Partial<{ name: string; totalCents: number; fundedCents: number; priority: number; dueOn: string }>) => {
if ("totalCents" in patch && "fundedCents" in patch && (patch.totalCents ?? 0) < (patch.fundedCents ?? 0)) {
patch.fundedCents = patch.totalCents;
}
updateM.mutate({ id, body: patch }, {
onSuccess: () => push("ok", "Plan updated"),
onError: (err: any) => push("err", err?.message ?? "Update failed"),
});
};
const onDelete = (id: number) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Plan deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed"),
});
};
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
{/* KPI strip */}
<div className="grid gap-2 sm:grid-cols-3 mb-4">
<div className="card kpi"><h3>Funded</h3><div className="val"><Money cents={totals.funded} /></div></div>
<div className="card kpi"><h3>Total</h3><div className="val"><Money cents={totals.total} /></div></div>
<div className="card kpi"><h3>Remaining</h3><div className="val"><Money cents={totals.remaining} /></div></div>
</div>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-48" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="Total $" type="number" min={0} step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} />
<input className="input w-28" placeholder="Funded $" type="number" min={0} step="0.01" value={funded} onChange={(e) => setFunded(e.target.value)} />
<input className="input w-24" placeholder="Priority" type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<input className="input w-40" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
<button className="btn" disabled={!name || !total || createM.isPending}>Add</button>
</form>
{/* Table */}
{data.fixedPlans.length === 0 ? (
<div className="muted text-sm">No fixed plans yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Due</th><th>Priority</th>
<th>Funded</th><th>Total</th><th>Remaining</th><th>Status</th><th></th>
</tr>
</thead>
<tbody>
{data.fixedPlans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => {
const remaining = Math.max(0, p.totalCents - p.fundedCents);
return (
<tr key={p.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={p.name} onChange={(v) => onEdit(p.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditDate value={p.dueOn} onChange={(iso) => onEdit(p.id, { dueOn: iso })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={p.priority} min={0} onChange={(n) => onEdit(p.id, { priority: n })} />
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) => onEdit(p.id, { fundedCents: Math.max(0, Math.min(cents, p.totalCents)) })}
/>
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) => onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min(p.fundedCents, cents) })}
/>
</td>
<td className="px-3 py-2"><Money cents={remaining} /></td>
<td className="px-3 py-2"><DueBadge dueISO={p.dueOn} /></td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(p.id)} disabled={deleteM.isPending}>Delete</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
</div>
);
}
/* --- Inline editors (minimal) --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(value);
const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); };
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
const commit = () => { const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0))); if (n !== value) onChange(n); setEditing(false); };
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
}
function InlineEditMoney({ valueCents, onChange }: { valueCents: number; onChange: (cents: number) => void }) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState((valueCents / 100).toFixed(2));
const commit = () => {
const cents = Math.max(0, Math.round((parseFloat(v || "0")) * 100));
if (cents !== valueCents) onChange(cents);
setEditing(false);
};
return editing ? (
<input className="input w-28" type="number" step="0.01" min={0} value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{(valueCents/100).toFixed(2)}</button>;
}
function InlineEditDate({ value, onChange }: { value: string; onChange: (iso: string) => void }) {
const [editing, setEditing] = useState(false);
const local = new Date(value);
const [v, setV] = useState(local.toISOString().slice(0, 10));
const commit = () => { const iso = new Date(v + "T00:00:00Z").toISOString(); if (iso !== value) onChange(iso); setEditing(false); };
return editing ? (
<input className="input w-40" type="date" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{new Date(value).toLocaleDateString()}</button>;
}

View File

@@ -0,0 +1,14 @@
import { NavLink } from "react-router-dom";
export default function SettingsNav() {
const link = (to: string, label: string) =>
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
return (
<div className="row mb-3">
<h2 className="section-title m-0">Settings</h2>
<div className="ml-auto flex gap-1">
{link("/settings/categories", "Categories")}
{link("/settings/plans", "Fixed Plans")}
</div>
</div>
);
}

132
web/src/styles.css Normal file
View File

@@ -0,0 +1,132 @@
:root {
--color-bg: #0b0c10;
--color-panel: #111318;
--color-fg: #e7e9ee;
--color-ink: #2a2e37;
--color-accent: #5dd6b2;
--radius-xl: 12px;
--radius-lg: 10px;
--radius-md: 8px;
--shadow-1: 0 6px 20px rgba(0,0,0,0.25);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { margin: 0; background: var(--color-bg); color: var(--color-fg); font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
a { color: inherit; text-decoration: none; }
.container { width: min(1100px, 100%); margin-inline: auto; padding: 0 16px; }
.muted { opacity: 0.7; }
.card {
background: var(--color-panel);
border: 1px solid var(--color-ink);
border-radius: var(--radius-xl);
padding: 16px;
box-shadow: var(--shadow-1);
}
.row { display: flex; align-items: center; }
.stack { display: grid; gap: 12px; }
.section-title { font-weight: 700; font-size: 16px; margin-bottom: 10px; }
.input {
background: #0f1116;
color: var(--color-fg);
border: 1px solid var(--color-ink);
border-radius: var(--radius-lg);
padding: 8px 10px;
outline: none;
}
.input:focus { border-color: var(--color-accent); }
.btn {
background: var(--color-accent);
color: #062016;
border: none;
border-radius: var(--radius-lg);
padding: 8px 12px;
font-weight: 700;
cursor: pointer;
}
.btn[disabled] { opacity: 0.5; cursor: default; }
.badge {
background: var(--color-ink);
border-radius: var(--radius-lg);
padding: 4px 8px;
font-size: 12px;
}
.table { width: 100%; border-collapse: separate; border-spacing: 0 8px; }
.table thead th {
text-align: left; font-size: 12px; opacity: 0.7; padding: 0 8px;
}
.table tbody tr {
background: var(--color-panel);
border: 1px solid var(--color-ink);
border-radius: var(--radius-xl);
}
.table td { padding: 8px; }
.toast-err {
background: #b21d2a;
color: white;
border-radius: var(--radius-lg);
padding: 10px 12px;
}
.border { border: 1px solid var(--color-ink); }
.rounded-xl { border-radius: var(--radius-xl); }
.divide-y > * + * { border-top: 1px solid var(--color-ink); }
/* utility-ish */
.w-44 { width: 11rem; }
.w-56 { width: 14rem; }
.w-40 { width: 10rem; }
.ml-auto { margin-left: auto; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mb-3 { margin-bottom: 0.75rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.flex-wrap { flex-wrap: wrap; }
.text-sm { font-size: 0.875rem; }
.text-xl { font-size: 1.25rem; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.bg-\[--color-ink\] { background: var(--color-ink); }
.bg-\[--color-ink\]\/60 { background: color-mix(in oklab, var(--color-ink), transparent 40%); }
.bg-\[--color-panel\] { background: var(--color-panel); }
.text-\[--color-fg\] { color: var(--color-fg); }
.border-\[--color-ink\] { border-color: var(--color-ink); }
.rounded-\[--radius-xl\] { border-radius: var(--radius-xl); }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.py-8 { padding-block: 2rem; }
.h-14 { height: 3.5rem; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.opacity-70 { opacity: .7; }
.grid { display: grid; }
.md\:grid-cols-2 { grid-template-columns: 1fr; }
.md\:grid-cols-3 { grid-template-columns: 1fr; }
@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: 1fr 1fr; }
.md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; }
}
.shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); }
.underline { text-decoration: underline; }
.fixed { position: fixed; }
.bottom-4 { bottom: 1rem; }
.left-1\/2 { left: 50%; }
.-translate-x-1\/2 { transform: translateX(-50%); }
.z-50 { z-index: 50; }
.space-y-2 > * + * { margin-top: .5rem; }
.space-y-8 > * + * { margin-top: 2rem; }
.space-y-6 > * + * { margin-top: 1.5rem; }
.overflow-x-auto { overflow-x: auto; }

View File

@@ -0,0 +1,77 @@
import type { FixedPlan, VariableCategory } from "../hooks/useDashboard";
export function previewAllocation(
amountCents: number,
fixedPlans: FixedPlan[],
variableCategories: VariableCategory[]
) {
let remaining = Math.max(0, amountCents | 0);
// Fixed pass: fill by priority then due date order (assume arrays are already sorted on the server;
// this is just a local fallback).
const fixedSorted = [...fixedPlans].sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime();
});
const fixed: Array<{ id: string; name: string; amountCents: number }> = [];
for (const p of fixedSorted) {
if (remaining <= 0) break;
const total = BigInt(p.totalCents ?? 0);
const funded = BigInt(p.fundedCents ?? 0);
const needBig = total - funded;
const need = Number(needBig > 0n ? needBig : 0n);
if (need <= 0) continue;
const give = Math.min(need, remaining);
fixed.push({ id: p.id, name: p.name, amountCents: give });
remaining -= give;
}
// Variable pass: largest remainder; savings-first on ties; then by priority then name.
const variable: Array<{ id: string; name: string; amountCents: number }> = [];
const cats = [...variableCategories].sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
if (remaining > 0 && cats.length > 0) {
const totalPercent = cats.reduce((s, c) => s + (c.percent || 0), 0);
const normCats =
totalPercent === 100
? cats
: cats.map((c) => ({
...c,
percent: totalPercent > 0 ? (c.percent * 100) / totalPercent : 0,
}));
const base: number[] = new Array(normCats.length).fill(0);
const tie: { idx: number; remainder: number; isSavings: boolean; priority: number; name: string }[] = [];
let sumBase = 0;
normCats.forEach((c, idx) => {
const exact = (remaining * c.percent) / 100;
const floor = Math.floor(exact);
base[idx] = floor;
sumBase += floor;
tie.push({ idx, remainder: exact - floor, isSavings: !!c.isSavings, priority: c.priority, name: c.name });
});
let leftovers = remaining - sumBase;
tie.sort((a, b) => {
if (a.isSavings !== b.isSavings) return a.isSavings ? -1 : 1;
if (a.remainder !== b.remainder) return b.remainder - a.remainder;
if (a.priority !== b.priority) return a.priority - b.priority;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < tie.length && leftovers > 0; i++, leftovers--) base[tie[i].idx] += 1;
normCats.forEach((c, idx) => {
const give = base[idx] || 0;
if (give > 0) variable.push({ id: c.id, name: c.name, amountCents: give });
});
remaining = leftovers;
}
return { fixed, variable, unallocatedCents: Math.max(0, remaining) };
}

6
web/src/utils/format.ts Normal file
View File

@@ -0,0 +1,6 @@
export function toMoney(cents: number) {
return (cents / 100).toLocaleString(undefined, { style: "currency", currency: "USD" });
}
export function nowLocalISOStringMinute() {
return new Date().toISOString().slice(0, 16);
}

9
web/src/utils/money.ts Normal file
View File

@@ -0,0 +1,9 @@
export function fmtMoney(cents: number) {
const dollars = (Number(cents || 0) / 100).toFixed(2);
return `$${dollars}`;
}
export const toCents = (val: string | number): number => {
const n = typeof val === "string" ? Number(val) : val;
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
};

View File

@@ -0,0 +1,120 @@
import { describe, it, expect } from "vitest";
import { previewAllocation, type FixedPlan, type VariableCategory } from "../src/utils/allocatorPreview";
const cats = (defs: Array<Partial<VariableCategory> & { name: string }>): VariableCategory[] =>
defs.map((d, i) => ({
id: i + 1,
name: d.name,
percent: d.percent ?? 0,
isSavings: d.isSavings ?? false,
priority: d.priority ?? 100,
}));
const plans = (defs: Array<Partial<FixedPlan> & { name: string; totalCents: number }>): FixedPlan[] =>
defs.map((d, i) => ({
id: i + 1,
name: d.name,
totalCents: d.totalCents,
fundedCents: d.fundedCents ?? 0,
priority: d.priority ?? 100,
dueOn: d.dueOn ?? new Date().toISOString(),
}));
describe("previewAllocation — basics", () => {
it("single bucket 100% gets all remaining after fixed", () => {
const fp = plans([]);
const vc = cats([{ name: "Only", percent: 100 }]);
const r = previewAllocation(1_000, fp, vc);
expect(r.fixed.length).toBe(0);
expect(r.variable).toEqual([{ id: 1, name: "Only", amountCents: 1_000 }]);
expect(r.unallocatedCents).toBe(0);
});
it("zero allocations when amount is 0", () => {
const r = previewAllocation(0, plans([]), cats([{ name: "A", percent: 100 }]));
expect(r.fixed.length).toBe(0);
expect(r.variable.length).toBe(0);
expect(r.unallocatedCents).toBe(0);
});
it("no variable split if no categories", () => {
const r = previewAllocation(500, plans([]), []);
expect(r.fixed.length).toBe(0);
expect(r.variable.length).toBe(0);
expect(r.unallocatedCents).toBe(500);
});
});
describe("previewAllocation — fixed first", () => {
it("funds plans in priority then due order up to need", () => {
const fp = plans([
{ name: "B", totalCents: 8000, fundedCents: 7000, priority: 2, dueOn: "2025-12-31T00:00:00Z" }, // need 1000
{ name: "A", totalCents: 5000, fundedCents: 0, priority: 1, dueOn: "2025-12-01T00:00:00Z" }, // need 5000 (but priority=1 => first)
]);
const vc = cats([{ name: "Var", percent: 100 }]);
const r = previewAllocation(4000, fp, vc);
// Fixed plan A (priority 1) gets as much as possible: 4000 (need 5000)
expect(r.fixed).toEqual([{ id: 2, name: "A", amountCents: 4000 }]);
expect(r.variable.length).toBe(0);
expect(r.unallocatedCents).toBe(0);
});
it("leftover goes to variables after satisfying plan needs", () => {
const fp = plans([{ name: "Rent", totalCents: 10000, fundedCents: 9000, priority: 1 }]); // need 1000
const vc = cats([{ name: "Groceries", percent: 100 }]);
const r = previewAllocation(2500, fp, vc);
expect(r.fixed).toEqual([{ id: 1, name: "Rent", amountCents: 1000 }]);
expect(r.variable).toEqual([{ id: 1, name: "Groceries", amountCents: 1500 }]);
expect(r.unallocatedCents).toBe(0);
});
});
describe("previewAllocation — largest remainder with savings-first tiebreak", () => {
it("splits by integer floor and then leftover by remainder", () => {
// 3 cats: 50%, 30%, 20%; amount 101 -> floors: 50, 30, 20 => sumBase=100, 1 leftover to largest remainder (all remainders 0, so go to savings-first if any)
const vc = cats([
{ name: "A", percent: 50, isSavings: false, priority: 10 },
{ name: "B", percent: 30, isSavings: true, priority: 10 },
{ name: "C", percent: 20, isSavings: false, priority: 10 },
]);
const r = previewAllocation(101, plans([]), vc);
// Base: A50, B30, C20 = 100; leftover=1 -> goes to B (savings-first)
expect(r.variable).toEqual([
{ id: 1, name: "A", amountCents: 50 },
{ id: 2, name: "B", amountCents: 31 },
{ id: 3, name: "C", amountCents: 20 },
]);
});
it("ties on remainder resolved by savings-first, then priority asc, then name asc", () => {
// Force equal remainders with 3 categories @ 33.333…%
const vc = cats([
{ name: "Zeta", percent: 33, isSavings: false, priority: 2 },
{ name: "Alpha", percent: 33, isSavings: false, priority: 1 },
{ name: "Saver", percent: 34, isSavings: true, priority: 5 },
]);
// Amount 4: exacts ~ 1.32, 1.32, 1.36 => floors 1,1,1 sumBase 3 leftover 1 -> goes to Saver (savings-first)
const r = previewAllocation(4, plans([]), vc);
expect(r.variable).toEqual([
{ id: 1, name: "Zeta", amountCents: 1 },
{ id: 2, name: "Alpha", amountCents: 1 },
{ id: 3, name: "Saver", amountCents: 2 },
]);
});
});
describe("previewAllocation — normalization safety", () => {
it("normalizes percents when sum != 100 to avoid crashing UI", () => {
const vc = cats([
{ name: "A", percent: 40 },
{ name: "B", percent: 40 },
{ name: "C", percent: 40 }, // sum = 120 -> normalize
]);
const r = previewAllocation(120, plans([]), vc);
// Expect all cents allocated (no unallocated) and proportions roughly 1/3 each.
const total = r.variable.reduce((s, v) => s + v.amountCents, 0);
expect(total).toBe(120);
expect(r.unallocatedCents).toBe(0);
});
});

28
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

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

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
web/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})

9
web/vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
reporters: "default",
},
});