diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 090959d..6f16896 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -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); }); }); diff --git a/src/store/actions.ts b/src/store/actions.ts index d830c85..697b118 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -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>, + changes: Partial>, ): void { const post = requirePost(draft, postId); - const resolved: Partial> = { ...changes }; + if ("folderId" in changes || "order" in changes) throw new Error("Use placeBudgetItem to move a post"); + const resolved: Partial> = { ...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>, + changes: Partial>, ): void { const cost = requireRecurringCost(draft, id); - const resolved: Partial> = { ...changes }; + if ("folderId" in changes || "order" in changes) throw new Error("Use placeBudgetItem to move a recurring cost"); + const resolved: Partial> = { ...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); } /**