more ui fix for rebalance, and safegaurd as well.
All checks were successful
Deploy / deploy (push) Successful in 1m28s
Security Tests / security-non-db (push) Successful in 18s
Security Tests / security-db (push) Successful in 24s

This commit is contained in:
2026-03-11 22:24:20 -05:00
parent e6dac3f344
commit 234ecc56e9
3 changed files with 132 additions and 32 deletions

View File

@@ -3355,6 +3355,7 @@ const ManualRebalanceBody = z.object({
}) })
), ),
forceLowerSavings: z.boolean().optional(), forceLowerSavings: z.boolean().optional(),
confirmOver80: z.boolean().optional(),
}); });
async function assertPercentTotal( async function assertPercentTotal(
@@ -3606,8 +3607,13 @@ app.post("/variable-categories/manual-rebalance", async (req, reply) => {
.send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` }); .send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` });
} }
const maxAllowed = Math.floor(availableCents * 0.8); const maxAllowed = Math.floor(availableCents * 0.8);
if (availableCents > 0 && targets.some((t) => t.target > maxAllowed)) { const over80 = availableCents > 0 && targets.some((t) => t.target > maxAllowed);
return reply.code(400).send({ ok: false, code: "OVER_80_PERCENT", message: "No category can exceed 80% of available." }); if (over80 && !parsed.data.confirmOver80) {
return reply.code(400).send({
ok: false,
code: "OVER_80_CONFIRM_REQUIRED",
message: "A category exceeds 80% of available. Confirm to proceed.",
});
} }
const totalSavingsBefore = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.currentBalance, 0); const totalSavingsBefore = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.currentBalance, 0);
const totalSavingsAfter = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.target, 0); const totalSavingsAfter = targets.filter((t) => t.isSavings).reduce((s, t) => s + t.target, 0);

View File

@@ -17,6 +17,7 @@ export type RebalanceInfo = {
export type ManualRebalanceBody = { export type ManualRebalanceBody = {
targets: Array<{ id: string; targetCents: number }>; targets: Array<{ id: string; targetCents: number }>;
forceLowerSavings?: boolean; forceLowerSavings?: boolean;
confirmOver80?: boolean;
}; };
export const rebalanceApi = { export const rebalanceApi = {

View File

@@ -8,6 +8,38 @@ function sum(values: number[]) {
return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0); return values.reduce((s, v) => s + (Number.isFinite(v) ? v : 0), 0);
} }
function normalizeTargetsToAvailable(
rows: Array<RebalanceCategory & { targetCents: number }>,
available: number
) {
const next = rows.map((r) => ({ ...r, targetCents: Math.max(0, Math.floor(r.targetCents || 0)) }));
const total = sum(next.map((r) => r.targetCents));
const diff = available - total;
if (diff === 0) return next;
if (diff > 0) {
const idx = next
.map((r, i) => ({ i, p: r.percent, t: r.targetCents }))
.sort((a, b) => b.p - a.p || b.t - a.t)[0]?.i;
if (idx === undefined) return null;
next[idx].targetCents += diff;
return next;
}
let needed = -diff;
const order = next
.map((r, i) => ({ i, t: r.targetCents }))
.sort((a, b) => b.t - a.t);
for (const o of order) {
if (needed <= 0) break;
const take = Math.min(next[o.i].targetCents, needed);
next[o.i].targetCents -= take;
needed -= take;
}
if (needed > 0) return null;
return next;
}
export default function RebalancePage() { export default function RebalancePage() {
const { push } = useToast(); const { push } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -17,9 +49,9 @@ export default function RebalancePage() {
}); });
const [rows, setRows] = useState<Array<RebalanceCategory & { targetCents: number }>>([]); const [rows, setRows] = useState<Array<RebalanceCategory & { targetCents: number }>>([]);
const [forceSavings, setForceSavings] = useState(false);
const [adjustId, setAdjustId] = useState<string>(""); const [adjustId, setAdjustId] = useState<string>("");
const [adjustValue, setAdjustValue] = useState<string>(""); const [adjustValue, setAdjustValue] = useState<string>("");
const [confirmOpen, setConfirmOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (data?.categories) { if (data?.categories) {
@@ -42,16 +74,19 @@ export default function RebalancePage() {
const savingsFloor = Math.floor(available * 0.2); const savingsFloor = Math.floor(available * 0.2);
const maxSingle = Math.floor(available * 0.8); const maxSingle = Math.floor(available * 0.8);
const hasNegative = rows.some((r) => r.targetCents < 0);
const over80Crossed = available > 0 && rows.some((r) => r.targetCents > maxSingle);
const sumMismatch = total !== available;
const savingsCrossed = savingsTotal < savingsBefore || savingsTotal < savingsFloor;
const errors: string[] = []; const errors: string[] = [];
const warnings: string[] = []; const warnings: string[] = [];
if (rows.some((r) => r.targetCents < 0)) errors.push("No category can be negative."); if (hasNegative) errors.push("No category can be negative.");
if (available > 0 && rows.some((r) => r.targetCents > maxSingle)) if (over80Crossed) warnings.push("A category exceeds 80% of available.");
warnings.push("A category exceeds 80% of available."); if (sumMismatch) warnings.push(`Totals differ from available ($${(available / 100).toFixed(2)}).`);
if (total !== available) warnings.push(`Totals differ from available ($${(available / 100).toFixed(2)}).`); if (savingsCrossed) warnings.push("Savings decreased and/or fell below the 20% floor.");
if ((savingsTotal < savingsBefore || savingsTotal < savingsFloor) && !forceSavings)
errors.push("Confirm lowering savings / below 20% floor.");
const canSubmit = errors.length === 0; const canSubmit = !hasNegative;
const applyAdjustOne = () => { const applyAdjustOne = () => {
if (!adjustId) return; if (!adjustId) return;
@@ -101,22 +136,62 @@ export default function RebalancePage() {
setRows(next); setRows(next);
}; };
const submitRebalance = async (
sourceRows: Array<RebalanceCategory & { targetCents: number }>,
opts?: { forceLowerSavings?: boolean; confirmOver80?: boolean }
) => {
const payload = {
targets: sourceRows.map((r) => ({ id: r.id, targetCents: r.targetCents })),
forceLowerSavings: opts?.forceLowerSavings,
confirmOver80: opts?.confirmOver80,
};
await rebalanceApi.submit(payload);
push("ok", "Rebalance applied");
setAdjustValue("");
await qc.invalidateQueries({ queryKey: ["rebalance", "info"] });
const refreshed = await refetch();
if (refreshed.data?.categories) {
setRows(refreshed.data.categories.map((c) => ({ ...c, targetCents: c.balanceCents })));
setAdjustId(refreshed.data.categories[0]?.id ?? "");
}
};
const submit = async () => { const submit = async () => {
if (!canSubmit) { if (hasNegative) {
push("err", errors[0] ?? "Fix validation errors first"); push("err", "No category can be negative.");
return; return;
} }
const payload = {
targets: rows.map((r) => ({ id: r.id, targetCents: r.targetCents })), if (over80Crossed || savingsCrossed || sumMismatch) {
forceLowerSavings: forceSavings, setConfirmOpen(true);
}; return;
}
try { try {
await rebalanceApi.submit(payload); await submitRebalance(rows);
push("ok", "Rebalance applied"); } catch (err: any) {
setForceSavings(false); const msg = err?.body?.message || err?.message || "Rebalance failed";
setAdjustValue(""); push("err", msg);
await qc.invalidateQueries({ queryKey: ["rebalance", "info"] }); }
await refetch(); };
const confirmAndApply = async () => {
try {
let targetRows = rows;
if (sumMismatch) {
const normalized = normalizeTargetsToAvailable(rows, available);
if (!normalized) {
push("err", "Could not auto-balance totals to available.");
return;
}
targetRows = normalized;
setRows(normalized);
}
await submitRebalance(targetRows, {
forceLowerSavings: savingsCrossed,
confirmOver80: over80Crossed,
});
setConfirmOpen(false);
} catch (err: any) { } catch (err: any) {
const msg = err?.body?.message || err?.message || "Rebalance failed"; const msg = err?.body?.message || err?.message || "Rebalance failed";
push("err", msg); push("err", msg);
@@ -134,7 +209,7 @@ export default function RebalancePage() {
</p> </p>
</header> </header>
<div className="card glass p-5 flex flex-wrap gap-6 items-center justify-between"> <div className="card glass p-5 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 items-center">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-xs uppercase tracking-wide muted">Available</div> <div className="text-xs uppercase tracking-wide muted">Available</div>
<div className="text-2xl font-mono font-semibold">${(available / 100).toFixed(2)}</div> <div className="text-2xl font-mono font-semibold">${(available / 100).toFixed(2)}</div>
@@ -149,14 +224,6 @@ export default function RebalancePage() {
<div className="text-xs uppercase tracking-wide muted">Savings total</div> <div className="text-xs uppercase tracking-wide muted">Savings total</div>
<div className="text-sm font-mono">${(savingsTotal / 100).toFixed(2)}</div> <div className="text-sm font-mono">${(savingsTotal / 100).toFixed(2)}</div>
</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>
<div className="card p-5 space-y-4"> <div className="card p-5 space-y-4">
@@ -253,7 +320,7 @@ export default function RebalancePage() {
className="btn ghost" className="btn ghost"
type="button" type="button"
onClick={async () => { onClick={async () => {
setForceSavings(false); setConfirmOpen(false);
setAdjustValue(""); setAdjustValue("");
const refreshed = await refetch(); const refreshed = await refetch();
if (refreshed.data?.categories) { if (refreshed.data?.categories) {
@@ -265,6 +332,32 @@ export default function RebalancePage() {
Reset Reset
</button> </button>
</div> </div>
{confirmOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="card p-6 max-w-lg w-[92vw] space-y-4">
<h3 className="text-lg font-semibold">Confirm safeguard overrides</h3>
<p className="text-sm muted">
You crossed one or more safeguards. Confirm to apply these changes.
</p>
<div className="text-sm space-y-1">
{over80Crossed && <div>- A category exceeds 80% of available.</div>}
{savingsCrossed && <div>- Savings decreased and/or is below 20% floor.</div>}
{sumMismatch && (
<div>- Totals do not match available; remaining difference will be auto-balanced.</div>
)}
</div>
<div className="flex gap-3 justify-end">
<button className="btn" onClick={() => setConfirmOpen(false)}>
Cancel
</button>
<button className="btn primary" onClick={confirmAndApply}>
Confirm and apply
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }