feat: manage nested budget folders
This commit is contained in:
@@ -4,6 +4,87 @@ import { createSeedDataset } from "../domain/seed.ts";
|
||||
import { occurrencesByMonth } from "../domain/occurrences.ts";
|
||||
import type { Dataset } from "../domain/types.ts";
|
||||
|
||||
const billInput = (postId: string, folderId: string | null = null) => ({
|
||||
name: "Phone", folderId, archived: false,
|
||||
amount: { amount: 30, currency: "DKK" }, startDate: "2026-09",
|
||||
recurrence: { kind: "everyNMonths" as const, n: 1 }, anchoring: "calendar" as const,
|
||||
splitMode: "percent" as const,
|
||||
splits: [{ postId, value: 100, absorbsRemainder: true }],
|
||||
});
|
||||
|
||||
describe("budget folder placement", () => {
|
||||
test("mixed siblings reorder densely", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
const folder = actions.addBudgetFolder(data, "Utilities", null);
|
||||
actions.addBudgetFolder(data, "Subscriptions", folder.id);
|
||||
const post = actions.addPost(data, "Bills", "DKK", folder.id);
|
||||
const bill = actions.addRecurringCost(data, billInput(post.id, folder.id));
|
||||
actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, folder.id, 0);
|
||||
const siblings = actions.budgetSiblings(data, folder.id);
|
||||
expect(siblings.map(item => item.order)).toEqual([0, 1, 2]);
|
||||
expect(siblings[0]!.id).toBe(bill.id);
|
||||
actions.placeBudgetItem(data, { kind: "folder", id: folder.id }, null, 0);
|
||||
expect(actions.budgetSiblings(data, null)[0]!.id).toBe(folder.id);
|
||||
});
|
||||
|
||||
test("invalid placement is atomic", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
const parent = actions.addBudgetFolder(data, "Parent", null);
|
||||
const child = actions.addBudgetFolder(data, "Child", parent.id);
|
||||
const post = actions.addPost(data, "Bills", "DKK", parent.id);
|
||||
const bill = actions.addRecurringCost(data, billInput(post.id));
|
||||
const invalid = [
|
||||
() => actions.placeBudgetItem(data, { kind: "post", id: post.id }, "ghost", 0),
|
||||
() => actions.placeBudgetItem(data, { kind: "folder", id: parent.id }, parent.id, 0),
|
||||
() => actions.placeBudgetItem(data, { kind: "folder", id: parent.id }, child.id, 0),
|
||||
() => actions.placeBudgetItem(data, { kind: "post", id: "ghost" }, null, 0),
|
||||
() => actions.placeBudgetItem(data, { kind: "recurring", id: bill.id }, null, 99),
|
||||
() => actions.addBudgetFolder(data, " ", null),
|
||||
() => actions.addBudgetFolder(data, "Unknown parent", "ghost"),
|
||||
() => actions.renameBudgetFolder(data, parent.id, " "),
|
||||
() => actions.addPost(data, "Orphan", "DKK", "ghost"),
|
||||
() => actions.addRecurringCost(data, billInput(post.id, "ghost")),
|
||||
() => actions.updatePost(data, post.id, { folderId: null } as never),
|
||||
() => actions.updateRecurringCost(data, bill.id, { order: 0 } as never),
|
||||
];
|
||||
for (const operation of invalid) {
|
||||
const before = JSON.stringify(data);
|
||||
expect(operation).toThrow();
|
||||
expect(JSON.stringify(data)).toBe(before);
|
||||
}
|
||||
actions.renameBudgetFolder(data, parent.id, "New name");
|
||||
expect(parent.name).toBe("New name");
|
||||
actions.placeBudgetItem(data, { kind: "folder", id: child.id }, null, 0);
|
||||
expect(child.parentId).toBe(null);
|
||||
expect(actions.budgetSiblings(data, parent.id).map(item => item.order)).toEqual([0]);
|
||||
});
|
||||
|
||||
test("legacy move buttons use mixed order", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
const bill = actions.addRecurringCost(data, billInput(data.posts[0]!.id));
|
||||
actions.moveRecurringCost(data, bill.id, -1);
|
||||
expect(actions.budgetSiblings(data, null).map(item => item.id)).toEqual([
|
||||
data.posts[0]!.id, data.posts[1]!.id, bill.id, data.posts[2]!.id,
|
||||
]);
|
||||
actions.movePost(data, data.posts[2]!.id, -1);
|
||||
expect(actions.budgetSiblings(data, null).map(item => item.id)).toEqual([
|
||||
data.posts[0]!.id, data.posts[1]!.id, data.posts[2]!.id, bill.id,
|
||||
]);
|
||||
const before = JSON.stringify(data);
|
||||
actions.movePost(data, data.posts[0]!.id, -1);
|
||||
expect(JSON.stringify(data)).toBe(before);
|
||||
});
|
||||
|
||||
test("new items append after sparse imported sibling orders", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
data.posts[2]!.order = 3;
|
||||
const folder = actions.addBudgetFolder(data, "Utilities", null);
|
||||
const post = actions.addPost(data, "Bills", "DKK");
|
||||
const bill = actions.addRecurringCost(data, billInput(post.id));
|
||||
expect([folder.order, post.order, bill.order]).toEqual([4, 5, 6]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Decimal places are one dataset-wide setting, so a different minor unit is
|
||||
* expressed by moving `settings.digits` — no monkey-patching a module
|
||||
@@ -480,8 +561,8 @@ describe("recurring costs", () => {
|
||||
const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" });
|
||||
|
||||
expect(first.id).not.toBe(second.id);
|
||||
expect(first.order).toBe(0);
|
||||
expect(second.order).toBe(1);
|
||||
expect(first.order).toBe(3);
|
||||
expect(second.order).toBe(4);
|
||||
expect(data.recurring.length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -754,8 +835,8 @@ describe("recurring costs", () => {
|
||||
const second = actions.addRecurringCost(data, { ...rentInput, name: "Phone" });
|
||||
actions.moveRecurringCost(data, second.id, -1);
|
||||
|
||||
expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(0);
|
||||
expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(1);
|
||||
expect(data.recurring.find((c) => c.id === second.id)!.order).toBe(3);
|
||||
expect(data.recurring.find((c) => c.id === first.id)!.order).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+106
-20
@@ -8,6 +8,8 @@ import type {
|
||||
Currency,
|
||||
CurrencyDef,
|
||||
Dataset,
|
||||
BudgetFolder,
|
||||
FolderId,
|
||||
FxRate,
|
||||
IsoDate,
|
||||
Money,
|
||||
@@ -43,6 +45,88 @@ function requirePost(draft: Dataset, postId: PostId): Post {
|
||||
return post;
|
||||
}
|
||||
|
||||
export type BudgetItemKey = { kind: "folder" | "post" | "recurring"; id: string };
|
||||
type BudgetItem = BudgetFolder | Post | RecurringCost;
|
||||
type BudgetSibling = BudgetItemKey & { order: number };
|
||||
|
||||
function siblingRefs(draft: Dataset, parentId: FolderId | null): Array<{ key: BudgetItemKey; item: BudgetItem }> {
|
||||
return [
|
||||
...draft.folders.filter(item => item.parentId === parentId).map(item => ({ key: { kind: "folder" as const, id: item.id }, item })),
|
||||
...draft.posts.filter(item => item.folderId === parentId).map(item => ({ key: { kind: "post" as const, id: item.id }, item })),
|
||||
...draft.recurring.filter(item => item.folderId === parentId).map(item => ({ key: { kind: "recurring" as const, id: item.id }, item })),
|
||||
].sort((a, b) => a.item.order - b.item.order);
|
||||
}
|
||||
|
||||
/** A clock-free, FX-free view of the shared order for Settings and placement. */
|
||||
export function budgetSiblings(draft: Dataset, parentId: FolderId | null): BudgetSibling[] {
|
||||
return siblingRefs(draft, parentId).map(({ key, item }) => ({ ...key, order: item.order }));
|
||||
}
|
||||
|
||||
function nextSiblingOrder(draft: Dataset, parentId: FolderId | null): number {
|
||||
const siblings = siblingRefs(draft, parentId);
|
||||
return siblings.length ? siblings[siblings.length - 1]!.item.order + 1 : 0;
|
||||
}
|
||||
|
||||
function requireFolder(draft: Dataset, id: FolderId): BudgetFolder {
|
||||
const folder = draft.folders.find(item => item.id === id);
|
||||
if (!folder) throw new Error(`Unknown folder: ${id}`);
|
||||
return folder;
|
||||
}
|
||||
|
||||
function requireFolderName(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error("Folder name cannot be blank");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function requireBudgetItem(draft: Dataset, key: BudgetItemKey): BudgetItem {
|
||||
if (key.kind === "folder") return requireFolder(draft, key.id);
|
||||
if (key.kind === "post") return requirePost(draft, key.id);
|
||||
if (key.kind === "recurring") return requireRecurringCost(draft, key.id);
|
||||
throw new Error(`Unknown budget item kind: ${String(key.kind)}`);
|
||||
}
|
||||
|
||||
function itemParent(key: BudgetItemKey, item: BudgetItem): FolderId | null {
|
||||
return key.kind === "folder" ? (item as BudgetFolder).parentId : (item as Post | RecurringCost).folderId;
|
||||
}
|
||||
|
||||
export function addBudgetFolder(draft: Dataset, name: string, parentId: FolderId | null): BudgetFolder {
|
||||
const resolvedName = requireFolderName(name);
|
||||
if (parentId !== null) requireFolder(draft, parentId);
|
||||
const folder: BudgetFolder = { id: newId(), name: resolvedName, parentId, order: nextSiblingOrder(draft, parentId) };
|
||||
draft.folders.push(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
export function renameBudgetFolder(draft: Dataset, id: FolderId, name: string): void {
|
||||
const folder = requireFolder(draft, id);
|
||||
const resolvedName = requireFolderName(name);
|
||||
folder.name = resolvedName;
|
||||
}
|
||||
|
||||
export function placeBudgetItem(draft: Dataset, key: BudgetItemKey, destination: FolderId | null, index: number): void {
|
||||
const item = requireBudgetItem(draft, key);
|
||||
if (destination !== null) requireFolder(draft, destination);
|
||||
if (key.kind === "folder") {
|
||||
let cursor = destination;
|
||||
while (cursor !== null) {
|
||||
if (cursor === key.id) throw new Error("A folder cannot be moved into itself or a descendant");
|
||||
cursor = requireFolder(draft, cursor).parentId;
|
||||
}
|
||||
}
|
||||
const source = itemParent(key, item);
|
||||
const sourceItems = siblingRefs(draft, source).filter(entry => !(entry.key.kind === key.kind && entry.key.id === key.id));
|
||||
const destinationItems = source === destination ? sourceItems : siblingRefs(draft, destination);
|
||||
if (!Number.isInteger(index) || index < 0 || index > destinationItems.length) {
|
||||
throw new Error(`Invalid sibling index: ${index}`);
|
||||
}
|
||||
if (key.kind === "folder") (item as BudgetFolder).parentId = destination;
|
||||
else (item as Post | RecurringCost).folderId = destination;
|
||||
destinationItems.splice(index, 0, { key, item });
|
||||
if (source !== destination) sourceItems.forEach((entry, order) => { entry.item.order = order; });
|
||||
destinationItems.forEach((entry, order) => { entry.item.order = order; });
|
||||
}
|
||||
|
||||
function requirePurchase(draft: Dataset, purchaseId: PurchaseId): Purchase {
|
||||
const purchase = draft.purchases.find((p) => p.id === purchaseId);
|
||||
if (!purchase) throw new Error(`Unknown purchase: ${purchaseId}`);
|
||||
@@ -129,12 +213,13 @@ export function setRuleOverride(
|
||||
month.ruleOverrides[postId] = roundRule(draft, rule);
|
||||
}
|
||||
|
||||
export function addPost(draft: Dataset, name: string, currency: Currency): Post {
|
||||
export function addPost(draft: Dataset, name: string, currency: Currency, folderId: FolderId | null = null): Post {
|
||||
if (folderId !== null) requireFolder(draft, folderId);
|
||||
const post: Post = {
|
||||
id: newId(),
|
||||
name,
|
||||
order: draft.posts.length,
|
||||
folderId: null,
|
||||
order: nextSiblingOrder(draft, folderId),
|
||||
folderId,
|
||||
archived: false,
|
||||
currency,
|
||||
rules: [],
|
||||
@@ -146,10 +231,11 @@ export function addPost(draft: Dataset, name: string, currency: Currency): Post
|
||||
export function updatePost(
|
||||
draft: Dataset,
|
||||
postId: PostId,
|
||||
changes: Partial<Omit<Post, "id">>,
|
||||
changes: Partial<Omit<Post, "id" | "folderId" | "order">>,
|
||||
): void {
|
||||
const post = requirePost(draft, postId);
|
||||
const resolved: Partial<Omit<Post, "id">> = { ...changes };
|
||||
if ("folderId" in changes || "order" in changes) throw new Error("Use placeBudgetItem to move a post");
|
||||
const resolved: Partial<Omit<Post, "id" | "folderId" | "order">> = { ...changes };
|
||||
Object.assign(post, resolved);
|
||||
}
|
||||
|
||||
@@ -163,13 +249,12 @@ export function setPostArchived(
|
||||
}
|
||||
|
||||
export function movePost(draft: Dataset, postId: PostId, direction: -1 | 1): void {
|
||||
const ordered = [...draft.posts].sort((a, b) => a.order - b.order);
|
||||
const index = ordered.findIndex((p) => p.id === postId);
|
||||
const post = requirePost(draft, postId);
|
||||
const ordered = budgetSiblings(draft, post.folderId);
|
||||
const index = ordered.findIndex(item => item.kind === "post" && item.id === postId);
|
||||
const target = index + direction;
|
||||
if (index === -1 || target < 0 || target >= ordered.length) return;
|
||||
const a = ordered[index]!;
|
||||
const b = ordered[target]!;
|
||||
[a.order, b.order] = [b.order, a.order];
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
placeBudgetItem(draft, { kind: "post", id: postId }, post.folderId, target);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -482,10 +567,11 @@ export function addRecurringCost(
|
||||
): RecurringCost {
|
||||
const recurrence = requireRecurrence(cost.recurrence);
|
||||
requireStartDateGranularity(recurrence, cost.startDate);
|
||||
if (cost.folderId !== null) requireFolder(draft, cost.folderId);
|
||||
const created: RecurringCost = {
|
||||
...cost,
|
||||
id: newId(),
|
||||
order: draft.recurring.length,
|
||||
order: nextSiblingOrder(draft, cost.folderId),
|
||||
recurrence,
|
||||
amount: roundMoneyValue(draft, cost.amount),
|
||||
splits: roundSplits(draft, cost.splits, cost.splitMode, cost.amount.currency),
|
||||
@@ -506,10 +592,11 @@ export function addRecurringCost(
|
||||
export function updateRecurringCost(
|
||||
draft: Dataset,
|
||||
id: RecurringCostId,
|
||||
changes: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom">>,
|
||||
changes: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom" | "folderId" | "order">>,
|
||||
): void {
|
||||
const cost = requireRecurringCost(draft, id);
|
||||
const resolved: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom">> = { ...changes };
|
||||
if ("folderId" in changes || "order" in changes) throw new Error("Use placeBudgetItem to move a recurring cost");
|
||||
const resolved: Partial<Omit<RecurringCost, "id" | "archived" | "endedFrom" | "folderId" | "order">> = { ...changes };
|
||||
|
||||
if (changes.recurrence) {
|
||||
resolved.recurrence = requireRecurrence(changes.recurrence);
|
||||
@@ -544,13 +631,12 @@ export function moveRecurringCost(
|
||||
id: RecurringCostId,
|
||||
direction: -1 | 1,
|
||||
): void {
|
||||
const ordered = [...draft.recurring].sort((a, b) => a.order - b.order);
|
||||
const index = ordered.findIndex((c) => c.id === id);
|
||||
const cost = requireRecurringCost(draft, id);
|
||||
const ordered = budgetSiblings(draft, cost.folderId);
|
||||
const index = ordered.findIndex(item => item.kind === "recurring" && item.id === id);
|
||||
const target = index + direction;
|
||||
if (index === -1 || target < 0 || target >= ordered.length) return;
|
||||
const a = ordered[index]!;
|
||||
const b = ordered[target]!;
|
||||
[a.order, b.order] = [b.order, a.order];
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
placeBudgetItem(draft, { kind: "recurring", id }, cost.folderId, target);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user