test run for user rebalance expenses feature, added safeguard for estimate exepenses acceptance in onboarding
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 19s
Security Tests / security-db (push) Successful in 24s

This commit is contained in:
2026-03-11 21:17:45 -05:00
parent cccce2c854
commit 3199e676a8
12 changed files with 583 additions and 18 deletions

View File

@@ -432,7 +432,6 @@ export default function OnboardingPage() {
const name = f.name.trim();
if (!name) continue; // skip empties just in case
try {
const amountMode = f.amountMode ?? "fixed";
const planAmountCents = Math.max(0, f.amountCents || 0);
const schedule = f.autoPayEnabled
? {
@@ -451,8 +450,6 @@ export default function OnboardingPage() {
name,
totalCents: planAmountCents,
fundedCents: 0,
amountMode,
estimatedCents: amountMode === "estimated" ? planAmountCents : null,
priority: i + 1,
dueOn: dueOnISO,
frequency: f.frequency,

View File

@@ -9,6 +9,7 @@ import {
type FormEvent,
} from "react";
import type React from "react";
import { Link } from "react-router-dom";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import {
SortableContext,
@@ -412,9 +413,14 @@ function CategoriesSettingsInner(
<header className="space-y-1">
<div className="flex items-center justify-between flex-wrap gap-2">
<h2 className="text-lg font-semibold">Expense Categories</h2>
{hasChanges && (
<span className="text-xs text-amber-400">Unsaved changes</span>
)}
<div className="flex items-center gap-3">
<Link className="btn" to="/settings/rebalance">
Rebalance pool
</Link>
{hasChanges && (
<span className="text-xs text-amber-400">Unsaved changes</span>
)}
</div>
</div>
<p className="text-sm muted">
Decide how every dollar is divided. Percentages must always add up to

View File

@@ -449,11 +449,6 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
name: plan.name,
totalCents: plan.totalCents,
fundedCents: plan.fundedCents ?? 0,
amountMode: plan.amountMode ?? "fixed",
estimatedCents:
(plan.amountMode ?? "fixed") === "estimated"
? (plan.estimatedCents ?? plan.totalCents)
: null,
priority: plan.priority,
dueOn: plan.dueOn,
frequency: plan.frequency,
@@ -502,10 +497,6 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
patch.frequency = local.frequency;
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
patch.nextPaymentDate = local.nextPaymentDate ?? null;
if ((local.amountMode ?? "fixed") !== (server.amountMode ?? "fixed"))
patch.amountMode = local.amountMode ?? "fixed";
if ((local.estimatedCents ?? null) !== (server.estimatedCents ?? null))
patch.estimatedCents = local.estimatedCents ?? 0;
if (Object.keys(patch).length > 0) {
await fixedPlansApi.update(local.id, patch);

View File

@@ -0,0 +1,245 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { rebalanceApi, type RebalanceCategory } from "../../api/rebalance";
import CurrencyInput from "../../components/CurrencyInput";
import { useToast } from "../../components/Toast";
function sum(values: number[]) {
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
}
export default function RebalancePage() {
const { push } = useToast();
const qc = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ["rebalance", "info"],
queryFn: rebalanceApi.fetchInfo,
});
const [rows, setRows] = useState<Array<RebalanceCategory & { targetCents: number }>>([]);
const [forceSavings, setForceSavings] = useState(false);
const [adjustId, setAdjustId] = useState<string>("");
const [adjustValue, setAdjustValue] = useState<string>("");
useEffect(() => {
if (data?.categories) {
setRows(data.categories.map((c) => ({ ...c, targetCents: c.balanceCents })));
setAdjustId(data.categories[0]?.id ?? "");
}
}, [data?.categories]);
const available = data?.availableCents ?? 0;
const total = useMemo(() => sum(rows.map((r) => r.targetCents)), [rows]);
const savingsTotal = useMemo(
() => sum(rows.filter((r) => r.isSavings).map((r) => r.targetCents)),
[rows]
);
const savingsBefore = useMemo(
() => sum(rows.filter((r) => r.isSavings).map((r) => r.balanceCents)),
[rows]
);
const savingsFloor = Math.floor(available * 0.2);
const maxSingle = Math.floor(available * 0.8);
const errors: string[] = [];
if (rows.some((r) => r.targetCents < 0)) errors.push("No category can be negative.");
if (available > 0 && rows.some((r) => r.targetCents > maxSingle))
errors.push("No category can exceed 80% of available.");
if (total !== available) errors.push(`Totals must equal available ($${(available / 100).toFixed(2)}).`);
if ((savingsTotal < savingsBefore || savingsTotal < savingsFloor) && !forceSavings)
errors.push("Lowering savings requires confirmation.");
const canSubmit = errors.length === 0;
const applyAdjustOne = () => {
if (!adjustId) return;
const desired = Math.round(Number(parseFloat(adjustValue || "0") * 100));
if (!Number.isFinite(desired) || desired < 0) {
push("err", "Enter a valid amount");
return;
}
if (desired > available) {
push("err", "Amount exceeds available budget");
return;
}
const others = rows.filter((r) => r.id !== adjustId);
if (others.length === 0) return;
const remaining = available - desired;
if (remaining < 0) {
push("err", "Amount exceeds available budget");
return;
}
const baseSum = sum(others.map((o) => o.targetCents)) || others.length;
const next = rows.map((r) => ({ ...r }));
let distributed = 0;
others.forEach((o) => {
const share = Math.floor((remaining * (o.targetCents || 1)) / baseSum);
const idx = next.findIndex((n) => n.id === o.id);
next[idx].targetCents = share;
distributed += share;
});
const leftover = remaining - distributed;
if (leftover > 0) {
const firstIdx = next.findIndex((n) => n.id !== adjustId);
if (firstIdx >= 0) next[firstIdx].targetCents += leftover;
}
const targetIdx = next.findIndex((n) => n.id === adjustId);
next[targetIdx].targetCents = desired;
setRows(next);
};
const submit = async () => {
if (!canSubmit) {
push("err", errors[0] ?? "Fix validation errors first");
return;
}
const payload = {
targets: rows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
forceLowerSavings: forceSavings,
};
try {
await rebalanceApi.submit(payload);
push("ok", "Rebalance applied");
setForceSavings(false);
setAdjustValue("");
await qc.invalidateQueries({ queryKey: ["rebalance", "info"] });
await refetch();
} catch (err: any) {
const msg = err?.body?.message || err?.message || "Rebalance failed";
push("err", msg);
}
};
if (isLoading || !data) return <div className="muted">Loading</div>;
return (
<div className="space-y-4">
<header className="space-y-1">
<h2 className="text-lg font-semibold">Rebalance variable pool</h2>
<p className="text-sm muted">
Redistribute your current variable pool without changing future income percentages.
</p>
</header>
<div className="card p-4 flex flex-wrap gap-4 items-center justify-between">
<div className="text-sm">
<div className="font-semibold">Available</div>
<div className="text-xl font-mono">${(available / 100).toFixed(2)}</div>
</div>
<div className="text-sm">
<div>Totals</div>
<div className="font-mono">
${(total / 100).toFixed(2)} / ${(available / 100).toFixed(2)}
</div>
</div>
<div className="text-sm">
<div>Savings total</div>
<div className="font-mono">${(savingsTotal / 100).toFixed(2)}</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={forceSavings}
onChange={(e) => setForceSavings(e.target.checked)}
/>
<span>Confirm lowering savings / below 20% floor</span>
</label>
</div>
<div className="card p-4 space-y-3">
<div className="flex flex-wrap gap-2 items-end">
<div className="flex flex-col">
<label className="text-xs muted">Adjust one category</label>
<select
className="input"
value={adjustId}
onChange={(e) => setAdjustId(e.target.value)}
>
{rows.map((r) => (
<option key={r.id} value={r.id}>
{r.name} {r.isSavings ? "(Savings)" : ""}
</option>
))}
</select>
</div>
<div className="flex flex-col">
<label className="text-xs muted">Amount</label>
<input
className="input"
type="number"
min={0}
step="0.01"
value={adjustValue}
onChange={(e) => setAdjustValue(e.target.value)}
placeholder="0.00"
/>
</div>
<button className="btn" type="button" onClick={applyAdjustOne}>
Apply helper
</button>
</div>
<div className="overflow-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left">
<th className="py-2">Category</th>
<th className="py-2">Current</th>
<th className="py-2">Percent</th>
<th className="py-2">Target</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-t border-[--color-panel]">
<td className="py-2 font-medium">
{row.name} {row.isSavings ? <span className="text-xs text-emerald-500">Savings</span> : null}
</td>
<td className="py-2 font-mono">${(row.balanceCents / 100).toFixed(2)}</td>
<td className="py-2">{row.percent}%</td>
<td className="py-2">
<CurrencyInput
className="w-32"
valueCents={row.targetCents}
onChange={(cents) =>
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, targetCents: cents } : r))
)
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{errors.length > 0 && (
<div className="toast-err space-y-1">
{errors.map((err) => (
<div key={err}>{err}</div>
))}
</div>
)}
<div className="flex gap-3">
<button className="btn primary" type="button" onClick={submit} disabled={!canSubmit}>
Apply rebalance
</button>
<button
className="btn"
type="button"
onClick={async () => {
setForceSavings(false);
setAdjustValue("");
await refetch();
}}
>
Reset
</button>
</div>
</div>
);
}

View File

@@ -10,8 +10,9 @@ import PlansSettings, { type PlansSettingsHandle } from "./PlansSettings";
import AccountSettings from "./AccountSettings";
import ThemeSettings from "./ThemeSettings";
import ReconcileSettings from "./ReconcileSettings";
import RebalancePage from "./RebalancePage";
type Tab = "categories" | "plans" | "account" | "theme" | "reconcile";
type Tab = "categories" | "rebalance" | "plans" | "account" | "theme" | "reconcile";
export default function SettingsPage() {
const location = useLocation();
@@ -21,6 +22,7 @@ export default function SettingsPage() {
if (location.pathname.includes("/settings/account")) return "account";
if (location.pathname.includes("/settings/theme")) return "theme";
if (location.pathname.includes("/settings/reconcile")) return "reconcile";
if (location.pathname.includes("/settings/rebalance")) return "rebalance";
return "categories";
};
@@ -66,6 +68,7 @@ export default function SettingsPage() {
const tabs = [
{ id: "categories" as const, label: "Expenses" },
{ id: "rebalance" as const, label: "Rebalance" },
{ id: "plans" as const, label: "Fixed Expenses" },
{ id: "account" as const, label: "Account" },
{ id: "theme" as const, label: "Theme" },
@@ -135,6 +138,8 @@ export default function SettingsPage() {
onDirtyChange={setIsDirty}
/>
);
case "rebalance":
return <RebalancePage />;
case "plans":
return (
<PlansSettings ref={plansRef} onDirtyChange={setIsDirty} />