Merge main into the anchored model

Main moved on by 27 commits since this branch forked, including three schema
steps of its own (budget folders, placement, nested bills). Resolutions worth
knowing:

- The anchored-model migration becomes 10 -> 11; main's 7 -> 10 run first.
- Main's nextUnconfirmedOccurrenceOnOrAfter walked the calendar grid for
  every cost, which would have shown the slot-model "next due" for an
  anchored bill in the budget tree. Both walks are now generators and the
  lookup dispatches on anchoring, like occurrencesOf.
- A folder's "Bills due" summed only occurrences, and an anchored bill's
  charges are never occurrences, so a charged phone plan read as 0 due.
  budgetTree now adds an anchored bill's charges in the month.
- Main's RecurringSection (with RecurringSplitEditor) supersedes this
  branch's split editor; the anchoring rules are ported onto it: start-date
  validation via startDateNeedsDay, month precision hidden for lastCharge,
  the anchoring control locked once a cost has charges.
- PurchaseDialog keeps main's placement control. The "Recurring cost" select
  replaces main's "Unlink from bill" checkbox, since "None" does the same.
  A bill created from the dialog nests under its post like one from Settings.
- Dropped main's "bounded projection ignores invalid confirmations" test: it
  exercised slot confirmations on a lastCharge cost, which no longer exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-09-30 16:51:17 +02:00
commit 933027cb1b
89 files changed
+4023 -651

No files matched your search

+120 -11
View File
@@ -6,6 +6,7 @@ import {
ImportValidationError,
exportFilename,
} from "./json.ts";
import { addRecurringCost, updatePost, updateRecurringCost } from "../store/actions.ts";
import { createSeedDataset } from "../domain/seed.ts";
import { monthView, yearView } from "../domain/views.ts";
import { occurrencesByMonth } from "../domain/occurrences.ts";
@@ -53,6 +54,11 @@ describe("round trip", () => {
});
describe("validation", () => {
test("accepts a purchase with no post and preserves it through export", () => {
const data = populated();
data.purchases[0]!.splits = [];
expect(parseDatasetJson(exportDatasetJson(data)).purchases[0]!.splits).toEqual([]);
});
test("rejects malformed JSON", () => {
expect(() => parseDatasetJson("{not json")).toThrow(ImportValidationError);
});
@@ -89,12 +95,6 @@ describe("validation", () => {
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/exactly one/i);
});
test("rejects a purchase with no splits", () => {
const data = populated();
data.purchases[0]!.splits = [];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/at least one split/i);
});
test("rejects an unsupported currency", () => {
const data = populated() as any;
// CHF, not GBP: GBP is one of the currencies the app ships with now.
@@ -115,6 +115,101 @@ describe("validation", () => {
});
});
describe("import rejects invalid folder graph", () => {
const folder = (id: string, parentId: string | null, order: number) => ({ id, name: id, parentId, order });
const parse = (data: Dataset) => parseDatasetJson(JSON.stringify(data));
test("round-trips a standalone purchase and a bill nested beneath a post", () => {
const data = populated();
data.folders = [folder("home", null, 3)];
data.purchases[0]!.placement = { folderId: "home", order: 0 };
data.recurring.push({ id: "rent", name: "Rent", order: 0, folderId: null, parentPostId: data.posts[0]!.id, archived: false, amount: { amount: 100, currency: "DKK" }, startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }] });
const restored = parse(data);
expect(restored.purchases[0]!.placement?.folderId).toBe("home");
expect(restored.recurring[0]!.parentPostId).toBe(data.posts[0]!.id);
restored.recurring[0]!.parentPostId = "missing";
expect(() => parse(restored)).toThrow(/parent post/);
restored.recurring[0]!.parentPostId = data.posts[0]!.id;
restored.purchases[0]!.placement!.folderId = "missing";
expect(() => parse(restored)).toThrow(/unknown folder/);
});
test("valid nested tree parses", () => {
const data = populated();
data.folders = [folder("home", null, 0), folder("bills", "home", 0)];
data.posts[0]!.folderId = "bills";
expect(parse(data).posts[0]!.folderId).toBe("bills");
});
test("rejects duplicate folder IDs", () => {
const data = populated();
data.folders = [folder("same", null, 0), folder("same", null, 1)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects missing parent", () => {
const data = populated();
data.folders = [folder("child", "missing", 0)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects missing item folders", () => {
const data = populated();
data.posts[0]!.folderId = "missing";
expect(() => parse(data)).toThrow(ImportValidationError);
data.posts[0]!.folderId = null;
data.recurring.push({
id: "rent", name: "Rent", order: 3, folderId: "missing", archived: false,
amount: { amount: 100, currency: "DKK" }, startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects cycles", () => {
const data = populated();
data.folders = [folder("a", "b", 0), folder("b", "a", 0)];
expect(() => parse(data)).toThrow(ImportValidationError);
});
test("rejects duplicate sibling order across item kinds", () => {
const data = populated();
data.folders = [folder("bills", null, 0)];
data.posts[0]!.order = 0;
expect(() => parse(data)).toThrow(ImportValidationError);
});
test.each([
["", "Rent"], [" ", "Rent"], ["Games", ""], ["Games", " "],
])("blank item names (%j, %j) survive saved dataset round trips and v7 import", (postName, billName) => {
const data = populated();
const bill = addRecurringCost(data, {
name: "Rent", folderId: null, archived: false,
amount: { amount: 100, currency: "DKK" }, startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
});
updatePost(data, data.posts[0]!.id, { name: postName });
updateRecurringCost(data, bill.id, { name: billName });
expect(parseDatasetJson(exportDatasetJson(data))).toEqual(data);
const legacy = JSON.parse(exportDatasetJson(data));
legacy.settings.schemaVersion = 7;
delete legacy.folders;
for (const item of [...legacy.posts, ...legacy.recurring]) delete item.folderId;
const imported = parseDatasetJson(JSON.stringify(legacy));
expect(imported.posts[0]!.name).toBe(postName);
expect(imported.recurring[0]!.name).toBe(billName);
expect(imported.folders).toEqual([]);
});
test.each(["", " "])("still rejects blank folder names %j", name => {
const data = populated();
data.folders = [{ ...folder("home", null, data.posts.length), name }];
expect(() => parse(data)).toThrow(/empty name/);
});
});
test("describeDataset counts what an import would replace", () => {
expect(describeDataset(populated())).toEqual({
posts: 3,
@@ -231,6 +326,19 @@ describe("rule versions on import", () => {
data.posts[0].rules = [{ from: "2026-01", rule: { kind: "everySecondTuesday" } }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/everySecondTuesday/);
});
test.each([
["missing percent", { kind: "percentOfIncome" }],
["string percent", { kind: "percentOfIncome", percent: "10" }],
["null percent", { kind: "percentOfIncome", percent: null }],
["missing fixed amount", { kind: "fixed", amount: { currency: "DKK" } }],
["string fixed amount", { kind: "fixed", amount: { amount: "500", currency: "DKK" } }],
["null fixed amount", { kind: "fixed", amount: { amount: null, currency: "DKK" } }],
])("rejects a %s before it can corrupt the fold", (_label, rule) => {
const data = populated() as any;
data.posts[0].rules = [{ from: "2026-01", rule }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(ImportValidationError);
});
});
describe("the decimal-places setting on import", () => {
@@ -274,7 +382,8 @@ describe("recurring cost validation", () => {
const valid = {
id: "r1",
name: "Rent",
order: 0,
order: 3,
folderId: null,
archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
@@ -518,7 +627,7 @@ describe("Purchase.source validation", () => {
test("a source with a malformed occurrence date is refused", () => {
const data: any = JSON.parse(withSource({ recurringId: "r1", occurrenceDate: "nope" }));
data.recurring = [{
id: "r1", name: "Rent", order: 0, archived: false,
id: "r1", name: "Rent", order: 3, folderId: null, archived: false,
amount: { amount: 8000, currency: "DKK" },
startDate: "2026-01",
recurrence: { kind: "everyNMonths", n: 1 },
@@ -570,7 +679,7 @@ describe("the occurrenceDate / anchoring pairing", () => {
const data: any = createSeedDataset("2026-09");
data.posts[0].id = "housing";
data.recurring = [{
id: "r1", name: "Bill", order: 0, archived: false,
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
amount: { amount: 300, currency: "DKK" },
startDate: anchoring === "lastCharge" ? "2026-09-01" : "2026-09",
recurrence: { kind: "everyNMonths", n: 1 },
@@ -638,7 +747,7 @@ describe("startDate granularity under the anchored model", () => {
const data: any = createSeedDataset("2026-09");
data.posts[0].id = "housing";
data.recurring = [{
id: "r1", name: "Bill", order: 0, archived: false,
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
amount: { amount: 300, currency: "DKK" },
startDate, recurrence: kind === "everyNMonths" ? { kind, n: 1 } : { kind, n: 30 },
anchoring, splitMode: "percent",
@@ -679,7 +788,7 @@ describe("an anchored cost's CHARGE dates need a day at the import boundary", ()
const data: any = createSeedDataset("2026-09");
data.posts[0].id = "housing";
data.recurring = [{
id: "r1", name: "Bill", order: 0, archived: false,
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
amount: { amount: 300, currency: "DKK" },
// Before the charge, so the charge's own date is the anchor the walk
// uses rather than being masked by `max(latest, startDate)`.