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

@@ -12,6 +12,15 @@ const NewCat = z.object({
});
const PatchCat = NewCat.partial();
const IdParam = z.object({ id: z.string().min(1) });
const ManualRebalanceBody = z.object({
targets: z.array(
z.object({
id: z.string().min(1),
targetCents: z.number().int().min(0),
})
),
forceLowerSavings: z.boolean().optional(),
});
function computeBalanceTargets(
categories: Array<{ id: string; percent: number }>,
@@ -66,6 +75,13 @@ async function assertPercentTotal100(tx: Prisma.TransactionClient, userId: strin
// The frontend will ensure 100% total before finishing onboarding
}
async function getLatestBudgetSession(userId: string) {
return prisma.budgetSession.findFirst({
where: { userId },
orderBy: { periodStart: "desc" },
});
}
const plugin: FastifyPluginAsync = async (app) => {
// CREATE
app.post("/variable-categories", async (req, reply) => {
@@ -182,6 +198,129 @@ const plugin: FastifyPluginAsync = async (app) => {
return reply.send({ ok: true, applied: true, totalBalance });
});
// MANUAL REBALANCE: set explicit dollar targets for variable balances
app.get("/variable-categories/manual-rebalance", async (req, reply) => {
const userId = req.userId;
const session = await getLatestBudgetSession(userId);
if (!session) {
return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION" });
}
const cats = await prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
});
return reply.send({
ok: true,
availableCents: Number(session.availableCents ?? 0n),
categories: cats.map((c) => ({ ...c, balanceCents: Number(c.balanceCents ?? 0n) })),
});
});
app.post("/variable-categories/manual-rebalance", async (req, reply) => {
const userId = req.userId;
const body = ManualRebalanceBody.safeParse(req.body);
if (!body.success || body.data.targets.length === 0) {
return reply.code(400).send({ ok: false, code: "INVALID_BODY" });
}
const session = await getLatestBudgetSession(userId);
if (!session) {
return reply.code(400).send({ ok: false, code: "NO_BUDGET_SESSION", message: "No active budget session found." });
}
const availableCents = Number(session.availableCents ?? 0n);
const cats = await prisma.variableCategory.findMany({
where: { userId },
orderBy: [{ priority: "asc" }, { name: "asc" }],
select: { id: true, name: true, percent: true, isSavings: true, balanceCents: true },
});
if (cats.length === 0) {
return reply.code(400).send({ ok: false, code: "NO_CATEGORIES" });
}
const targetMap = new Map<string, number>();
for (const t of body.data.targets) {
if (targetMap.has(t.id)) {
return reply.code(400).send({ ok: false, code: "DUPLICATE_ID" });
}
targetMap.set(t.id, t.targetCents);
}
if (targetMap.size !== cats.length || cats.some((c) => !targetMap.has(c.id))) {
return reply.code(400).send({ ok: false, code: "MISSING_CATEGORY", message: "Targets must include every category." });
}
const targets = cats.map((c) => ({
...c,
target: targetMap.get(c.id)!,
currentBalance: Number(c.balanceCents ?? 0n),
}));
if (targets.some((t) => t.target < 0)) {
return reply.code(400).send({ ok: false, code: "NEGATIVE_TARGET" });
}
const sumTargets = targets.reduce((s, t) => s + t.target, 0);
if (sumTargets !== availableCents) {
return reply
.code(400)
.send({ ok: false, code: "SUM_MISMATCH", message: `Targets must sum to available (${availableCents}).` });
}
const maxAllowed = Math.floor(availableCents * 0.8);
if (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." });
}
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 savingsFloor = Math.floor(availableCents * 0.2);
const loweringSavings = totalSavingsAfter < totalSavingsBefore;
const belowFloor = totalSavingsAfter < savingsFloor;
if ((loweringSavings || belowFloor) && !body.data.forceLowerSavings) {
return reply.code(400).send({
ok: false,
code: "SAVINGS_FLOOR",
message: "Lowering savings requires confirmation.",
});
}
await prisma.$transaction(async (tx) => {
for (const t of targets) {
await tx.variableCategory.update({
where: { id: t.id },
data: { balanceCents: BigInt(t.target) },
});
}
await tx.transaction.create({
data: {
userId,
kind: "rebalance",
amountCents: 0n,
occurredAt: new Date(),
note: JSON.stringify({
availableCents,
before: targets.map((t) => ({ id: t.id, balanceCents: t.currentBalance })),
after: targets.map((t) => ({ id: t.id, balanceCents: t.target })),
totalSavingsBefore,
totalSavingsAfter,
}),
},
});
});
return reply.send({
ok: true,
availableCents,
categories: targets.map((t) => ({ id: t.id, balanceCents: t.target })),
});
});
};
export default plugin;

View File

@@ -23,6 +23,7 @@ export async function resetUser(userId: string) {
prisma.incomeEvent.deleteMany({ where: { userId } }),
prisma.fixedPlan.deleteMany({ where: { userId } }),
prisma.variableCategory.deleteMany({ where: { userId } }),
prisma.budgetSession.deleteMany({ where: { userId } }),
]);
await prisma.user.deleteMany({ where: { id: userId } });
}

View File

@@ -0,0 +1,110 @@
import request from "supertest";
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import appFactory from "./appFactory";
import { prisma, resetUser, ensureUser, U, cid, closePrisma } from "./helpers";
import type { FastifyInstance } from "fastify";
let app: FastifyInstance;
beforeAll(async () => {
app = await appFactory();
});
afterAll(async () => {
if (app) await app.close();
await closePrisma();
});
async function seedBasics() {
await resetUser(U);
await ensureUser(U);
await prisma.variableCategory.createMany({
data: [
{ id: cid("s"), userId: U, name: "savings", percent: 30, priority: 1, isSavings: true, balanceCents: 3000n },
{ id: cid("f"), userId: U, name: "food", percent: 20, priority: 2, isSavings: false, balanceCents: 2000n },
{ id: cid("g"), userId: U, name: "gas", percent: 30, priority: 3, isSavings: false, balanceCents: 3000n },
{ id: cid("m"), userId: U, name: "misc", percent: 20, priority: 4, isSavings: false, balanceCents: 2000n },
],
});
await prisma.budgetSession.create({
data: {
userId: U,
periodStart: new Date("2026-03-01T00:00:00Z"),
periodEnd: new Date("2026-04-01T00:00:00Z"),
totalBudgetCents: 10_000n,
allocatedCents: 0n,
fundedCents: 0n,
availableCents: 10_000n,
},
});
}
describe("manual rebalance", () => {
beforeEach(async () => {
await seedBasics();
});
it("rebalances when sums match available", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c) => ({ id: c.id, targetCents: 2500 })); // 4 * 2500 = 10000
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.send({ targets });
expect(res.statusCode).toBe(200);
expect(res.body?.ok).toBe(true);
const updated = await prisma.variableCategory.findMany({ where: { userId: U } });
expect(updated.every((c) => Number(c.balanceCents) === 2500)).toBe(true);
});
it("rejects sum mismatch", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 5000 : 1000 })); // 5000 + 3*1000 = 8000
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.send({ targets });
expect(res.statusCode).toBe(400);
expect(res.body?.code).toBe("SUM_MISMATCH");
});
it("requires savings confirmation when lowering below floor", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
// savings to 500 (below 20% of 10000 = 2000)
const targets = cats.map((c) => ({ id: c.id, targetCents: c.isSavings ? 500 : 3166 })); // 500 + 3*3166 = 9998 -> adjust
targets[1].targetCents += 2; // total 10000
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.send({ targets });
expect(res.statusCode).toBe(400);
expect(res.body?.code).toBe("SAVINGS_FLOOR");
const resOk = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.send({ targets, forceLowerSavings: true });
expect(resOk.statusCode).toBe(200);
});
it("blocks >80% single category", async () => {
const cats = await prisma.variableCategory.findMany({ where: { userId: U }, orderBy: { priority: "asc" } });
const targets = cats.map((c, i) => ({ id: c.id, targetCents: i === 0 ? 9000 : 333 }));
targets[1].targetCents += 1; // sum 10000
const res = await request(app.server)
.post("/variable-categories/manual-rebalance")
.set("x-user-id", U)
.send({ targets });
expect(res.statusCode).toBe(400);
expect(res.body?.code).toBe("OVER_80_PERCENT");
});
});

View File

@@ -0,0 +1,39 @@
# Variable Pool Rebalance / Transfer Feature
## Summary
- Allow users to redistribute current variable pool (BudgetSession.availableCents) by setting per-category dollar targets, without changing future percent-based allocations.
- Enforce guards: sum = available, savings floor confirm, non-negative, no single category >80%, warnings when lowering savings.
- New backend endpoint performs atomic balance updates and audit logging; fixed expenses unaffected.
## API
- `POST /variable-categories/manual-rebalance`
- Body: `{ targets: [{ id, targetCents }], forceLowerSavings?: boolean }`
- Uses latest BudgetSession by periodStart; availableCents is the pool to balance.
- Validations: targets cover every category; non-negative; sum(targets)=available; each ≤80% of available; lowering savings or savings <20% requires `forceLowerSavings`.
- Transaction: update category balanceCents to targets; insert transaction(kind=`rebalance`, note snapshot); fixed plans untouched.
- Response: `{ ok: true, availableCents, categories: [{ id, balanceCents }] }`.
## UI/UX
- New Rebalance page (Settings → Expenses tab entry point) showing availableCents and per-category balances.
- Editable dollar inputs with live total meter and inline errors for rule violations.
- Savings floor warning/confirm; optional helper to adjust one category and auto-scale others respecting floors.
- Confirmation modal summarizing before/after deltas.
## Data / Logic
- Active session = latest BudgetSession for user.
- Rebalance acts on current variable pool only; future income remains percent-based.
- Savings floor default 20% of available; lowering requires confirmation flag.
## Tests
- Sum=available happy path; savings unchanged.
- Lowering savings w/out flag → 400; with flag → OK.
- Savings total <20% w/out flag → 400; with flag → OK.
- >80% single category → 400.
- Sum mismatch → 400; negative target → 400.
- Negative existing balance allowed only if target >=0 (ending non-negative).
- Adjust-one helper unit: scaling respects floors.
- Audit entry created; fixed plans and percents unchanged.
## Assumptions
- availableCents equals dashboard “Available” variable pool.
- No localization requirements for new errors.

View File

@@ -11,8 +11,6 @@ export type NewPlan = {
name: string;
totalCents: number; // >= 0
fundedCents?: number; // optional, default 0
amountMode?: "fixed" | "estimated";
estimatedCents?: number | null;
priority: number; // int
dueOn: string; // ISO date
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";

26
web/src/api/rebalance.ts Normal file
View File

@@ -0,0 +1,26 @@
import { apiGet, apiPost } from "./http";
export type RebalanceCategory = {
id: string;
name: string;
percent: number;
isSavings: boolean;
balanceCents: number;
};
export type RebalanceInfo = {
ok: boolean;
availableCents: number;
categories: RebalanceCategory[];
};
export type ManualRebalanceBody = {
targets: Array<{ id: string; targetCents: number }>;
forceLowerSavings?: boolean;
};
export const rebalanceApi = {
fetchInfo: () => apiGet<RebalanceInfo>("/variable-categories/manual-rebalance"),
submit: (body: ManualRebalanceBody) =>
apiPost<RebalanceInfo>("/variable-categories/manual-rebalance", body),
};

View File

@@ -168,6 +168,14 @@ const router = createBrowserRouter(
</RequireAuth>
}
/>
<Route
path="/settings/rebalance"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route
path="/health"
element={

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,10 +413,15 @@ 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>
<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
100%.

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} />