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) <noreply@anthropic.com>
This commit is contained in:
1 parent
fe9f6c6dec
commit
de128e3c87
2 files changed
+107
-1
No files matched your search
@@ -302,3 +302,79 @@ describe("settings", () => {
|
|||||||
expect(data.fxRates).toEqual([]);
|
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
@@ -1,5 +1,5 @@
|
|||||||
import { newId } from "../domain/seed.ts";
|
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 { roundMoney } from "../domain/money.ts";
|
||||||
import type {
|
import type {
|
||||||
Currency,
|
Currency,
|
||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
Purchase,
|
Purchase,
|
||||||
PurchaseId,
|
PurchaseId,
|
||||||
Rule,
|
Rule,
|
||||||
|
RuleVersion,
|
||||||
Schedule,
|
Schedule,
|
||||||
Split,
|
Split,
|
||||||
} from "../domain/types.ts";
|
} 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];
|
[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(
|
export function addPurchase(
|
||||||
draft: Dataset,
|
draft: Dataset,
|
||||||
purchase: Omit<Purchase, "id">,
|
purchase: Omit<Purchase, "id">,
|
||||||
|
|||||||
Reference in new issue
Block a user