feat(store): add setRuleFrom and removeRuleFrom

setRuleFrom replaces rather than appends for a month that already has a
version, keeping the effective rule unambiguous, and keeps the series sorted.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 14:07:33 +02:00
co-authored by Claude Opus 5
parent fe9f6c6dec
commit de128e3c87
2 changed files with 107 additions and 1 deletions
+76
View File
@@ -302,3 +302,79 @@ describe("settings", () => {
expect(data.fxRates).toEqual([]);
});
});
describe("rule versions", () => {
const pct = (percent: number) => ({ kind: "percentOfIncome" as const, percent });
test("setRuleFrom adds a version to a post that had none", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", pct(10));
expect(data.posts[0]!.rules).toEqual([{ from: "2026-04", rule: pct(10) }]);
});
test("setRuleFrom REPLACES an existing version for the same month", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", pct(10));
actions.setRuleFrom(data, postId, "2026-04", pct(15));
expect(data.posts[0]!.rules).toHaveLength(1);
expect(data.posts[0]!.rules[0]!.rule).toEqual(pct(15));
});
test("versions are kept sorted regardless of insertion order", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-07", pct(15));
actions.setRuleFrom(data, postId, "2026-04", pct(10));
actions.setRuleFrom(data, postId, "2026-05", pct(12));
expect(data.posts[0]!.rules.map((v) => v.from)).toEqual([
"2026-04",
"2026-05",
"2026-07",
]);
});
test("a fixed rule is rounded at the mutation boundary", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", {
kind: "fixed",
amount: { amount: 12.345, currency: "DKK" },
});
const stored = data.posts[0]!.rules[0]!.rule;
expect(stored.kind === "fixed" && stored.amount.amount).toBe(12.35);
});
test("a percentage above 100 is stored, not clamped", () => {
const data = draft();
actions.setRuleFrom(data, data.posts[0]!.id, "2026-04", pct(150));
expect(data.posts[0]!.rules[0]!.rule).toEqual(pct(150));
});
test("removeRuleFrom deletes exactly that version", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", pct(10));
actions.setRuleFrom(data, postId, "2026-07", pct(15));
actions.removeRuleFrom(data, postId, "2026-04");
expect(data.posts[0]!.rules).toEqual([{ from: "2026-07", rule: pct(15) }]);
});
test("removing a month that has no version is a no-op, not an error", () => {
const data = draft();
const postId = data.posts[0]!.id;
actions.setRuleFrom(data, postId, "2026-04", pct(10));
actions.removeRuleFrom(data, postId, "2026-09");
expect(data.posts[0]!.rules).toHaveLength(1);
});
test("both throw on an unknown post id", () => {
expect(() => actions.setRuleFrom(draft(), "ghost", "2026-04", pct(10))).toThrow(
/Unknown post: ghost/,
);
expect(() => actions.removeRuleFrom(draft(), "ghost", "2026-04")).toThrow(
/Unknown post: ghost/,
);
});
});
+31 -1
View File
@@ -1,5 +1,5 @@
import { newId } from "../domain/seed.ts";
import { monthOf } from "../domain/months.ts";
import { compareMonths, monthOf } from "../domain/months.ts";
import { roundMoney } from "../domain/money.ts";
import type {
Currency,
@@ -13,6 +13,7 @@ import type {
Purchase,
PurchaseId,
Rule,
RuleVersion,
Schedule,
Split,
} from "../domain/types.ts";
@@ -144,6 +145,35 @@ export function movePost(draft: Dataset, postId: PostId, direction: -1 | 1): voi
[a.order, b.order] = [b.order, a.order];
}
/**
* Inserts or replaces the version starting at `from`, keeping `rules` sorted.
* Replacing rather than appending is what keeps the effective rule
* unambiguous: two versions sharing a month would both be "in effect", and
* ruleAt would resolve to whichever the sort happened to leave last.
*/
export function setRuleFrom(
draft: Dataset,
postId: PostId,
from: MonthId,
rule: Rule,
): void {
const post = requirePost(draft, postId);
const version: RuleVersion = { from, rule: roundRule(rule) };
const existing = post.rules.findIndex((v) => v.from === from);
if (existing === -1) post.rules.push(version);
else post.rules[existing] = version;
post.rules.sort((a, b) => compareMonths(a.from, b.from));
}
/**
* Deletes the version starting at `from`. Removing the earliest one means the
* months before the next version now resolve to no rule, and allocate zero.
*/
export function removeRuleFrom(draft: Dataset, postId: PostId, from: MonthId): void {
const post = requirePost(draft, postId);
post.rules = post.rules.filter((v) => v.from !== from);
}
export function addPurchase(
draft: Dataset,
purchase: Omit<Purchase, "id">,