From 68712e72a38948c0ccb741344ceee8ffb3f4bdc8 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 02:15:34 +0200 Subject: [PATCH] feat(domain): allocation rules become a dated series Post.standingRule becomes Post.rules: RuleVersion[], resolved by ruleAt() as the last version effective at or before a month. Per-month overrides still win outright; a post with no applicable version allocates zero rather than applying its earliest rule backwards. allocationFor keeps its signature, so the fold, the views and the ODS export are untouched. Settings gets a read-only rule summary for now; the history editor follows. --- src/domain/allocation.test.ts | 207 ++++++++++++++++++++---- src/domain/allocation.ts | 38 ++++- src/domain/fold.test.ts | 4 +- src/domain/seed.test.ts | 7 +- src/domain/seed.ts | 6 +- src/domain/types.ts | 13 +- src/domain/views.test.ts | 2 +- src/export/ods.test.ts | 2 +- src/store/actions.test.ts | 46 +----- src/store/actions.ts | 12 +- src/ui/routes/settings/PostsSection.tsx | 66 ++------ 11 files changed, 252 insertions(+), 151 deletions(-) diff --git a/src/domain/allocation.test.ts b/src/domain/allocation.test.ts index 75c8f8d..6b7360b 100644 --- a/src/domain/allocation.test.ts +++ b/src/domain/allocation.test.ts @@ -1,18 +1,26 @@ import { test, expect, describe } from "bun:test"; import { resolveRule, + ruleAt, ruleForPost, isOverridden, allocationFor, incomeFor, } from "./allocation.ts"; -import type { Dataset, FxRate, Post } from "./types.ts"; +import { foldBalances, figuresFor } from "./fold.ts"; +import type { Dataset, FxRate, Post, Rule } from "./types.ts"; const rates: FxRate[] = [ { currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" }, { currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" }, ]; +const pct = (percent: number): Rule => ({ kind: "percentOfIncome", percent }); +const fixed = (amount: number): Rule => ({ + kind: "fixed", + amount: { amount, currency: "DKK" }, +}); + function post(overrides: Partial = {}): Post { return { id: "p1", @@ -20,18 +28,18 @@ function post(overrides: Partial = {}): Post { order: 0, archived: false, currency: "DKK", - standingRule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } }, + rules: [{ from: "2026-01", rule: fixed(400) }], ...overrides, }; } function dataset(overrides: Partial = {}): Dataset { return { - settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 }, fxRates: rates, posts: [post()], months: [ - { id: "2026-09", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }, + { id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }, ], purchases: [], ...overrides, @@ -40,9 +48,7 @@ function dataset(overrides: Partial = {}): Dataset { describe("resolveRule", () => { test("a fixed rule in base currency is its amount", () => { - expect( - resolveRule({ kind: "fixed", amount: { amount: 400, currency: "DKK" } }, 20000, "DKK", rates), - ).toBe(400); + expect(resolveRule(fixed(400), 20000, "DKK", rates)).toBe(400); }); test("a fixed rule in a foreign currency converts to base", () => { @@ -52,50 +58,113 @@ describe("resolveRule", () => { }); test("a percentage rule resolves against income", () => { - expect(resolveRule({ kind: "percentOfIncome", percent: 25 }, 20000, "DKK", rates)).toBe(5000); - }); - - test("a percentage rule rounds", () => { - expect(resolveRule({ kind: "percentOfIncome", percent: 33 }, 1000.5, "DKK", rates)).toBe(330.17); + expect(resolveRule(pct(25), 20000, "DKK", rates)).toBe(5000); }); test("a percentage above 100 is permitted", () => { - expect(resolveRule({ kind: "percentOfIncome", percent: 150 }, 1000, "DKK", rates)).toBe(1500); + expect(resolveRule(pct(150), 1000, "DKK", rates)).toBe(1500); }); }); -describe("override precedence", () => { - test("a month override wins over the standing rule", () => { +describe("ruleAt", () => { + const p = post({ + rules: [ + { from: "2026-04", rule: pct(10) }, + { from: "2026-07", rule: pct(15) }, + ], + }); + + test("returns null before the earliest version", () => { + expect(ruleAt(p, "2026-03")).toBeNull(); + }); + + test("applies from the version's own month", () => { + expect(ruleAt(p, "2026-04")?.rule).toEqual(pct(10)); + }); + + test("keeps applying between versions", () => { + expect(ruleAt(p, "2026-06")?.rule).toEqual(pct(10)); + }); + + test("the later version takes over from its month", () => { + expect(ruleAt(p, "2026-07")?.rule).toEqual(pct(15)); + }); + + test("the last version continues indefinitely", () => { + expect(ruleAt(p, "2030-11")?.rule).toEqual(pct(15)); + }); + + test("a post with no versions has no rule at all", () => { + expect(ruleAt(post({ rules: [] }), "2026-06")).toBeNull(); + }); + + test("resolves correctly even if the array is not sorted", () => { + const unsorted = post({ + rules: [ + { from: "2026-07", rule: pct(15) }, + { from: "2026-04", rule: pct(10) }, + ], + }); + expect(ruleAt(unsorted, "2026-05")?.rule).toEqual(pct(10)); + expect(ruleAt(unsorted, "2026-08")?.rule).toEqual(pct(15)); + }); +}); + +describe("precedence", () => { + test("a month override beats the effective version", () => { const data = dataset({ + posts: [post({ rules: [{ from: "2026-01", rule: fixed(400) }] })], months: [ { - id: "2026-09", + id: "2026-01", income: { amount: 20000, currency: "DKK" }, - ruleOverrides: { p1: { kind: "fixed", amount: { amount: 999, currency: "DKK" } } }, + ruleOverrides: { p1: fixed(999) }, }, ], }); - expect(allocationFor(data, "p1", "2026-09")).toBe(999); + expect(allocationFor(data, "p1", "2026-01")).toBe(999); expect(isOverridden(data.months[0], "p1")).toBe(true); }); - test("without an override the standing rule applies", () => { - const data = dataset(); - expect(allocationFor(data, "p1", "2026-09")).toBe(400); - expect(isOverridden(data.months[0], "p1")).toBe(false); + test("without an override the effective version applies", () => { + expect(allocationFor(dataset(), "p1", "2026-01")).toBe(400); }); - test("ruleForPost falls back to the standing rule when the month is unknown", () => { - expect(ruleForPost(undefined, post())).toEqual({ - kind: "fixed", - amount: { amount: 400, currency: "DKK" }, + test("no applicable version means an allocation of zero", () => { + const data = dataset({ posts: [post({ rules: [{ from: "2026-06", rule: fixed(400) }] })] }); + expect(allocationFor(data, "p1", "2026-01")).toBe(0); + }); + + test("a post with no versions allocates zero rather than throwing", () => { + expect(allocationFor(dataset({ posts: [post({ rules: [] })] }), "p1", "2026-01")).toBe(0); + }); + + test("an override still applies in a month with no version", () => { + const data = dataset({ + posts: [post({ rules: [] })], + months: [ + { + id: "2026-01", + income: { amount: 20000, currency: "DKK" }, + ruleOverrides: { p1: fixed(250) }, + }, + ], }); + expect(allocationFor(data, "p1", "2026-01")).toBe(250); + }); + + test("ruleForPost returns null when nothing applies", () => { + expect(ruleForPost(undefined, post({ rules: [] }), "2026-01")).toBeNull(); + }); + + test("an unknown post id throws", () => { + expect(() => allocationFor(dataset(), "nope", "2026-01")).toThrow(/nope/); }); }); describe("incomeFor", () => { test("returns the month's income", () => { - expect(incomeFor(dataset(), "2026-09")).toBe(20000); + expect(incomeFor(dataset(), "2026-01")).toBe(20000); }); test("a month with no record has zero income", () => { @@ -103,13 +172,81 @@ describe("incomeFor", () => { }); }); -test("a percentage allocation in a month with no record is zero", () => { - const data = dataset({ - posts: [post({ standingRule: { kind: "percentOfIncome", percent: 25 } })], - }); - expect(allocationFor(data, "p1", "2026-10")).toBe(0); -}); +describe("the fold across a rule change", () => { + // The feature's headline behaviour: 10% through June, 15% from July, with the + // rollover carrying correctly across the boundary. + const data: Dataset = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2 }, + fxRates: [], + posts: [ + post({ + rules: [ + { from: "2026-04", rule: pct(10) }, + { from: "2026-07", rule: pct(15) }, + ], + }), + ], + months: ["2026-05", "2026-06", "2026-07", "2026-08"].map((id) => ({ + id, + income: { amount: 10000, currency: "DKK" }, + ruleOverrides: {}, + })), + purchases: [], + }; -test("an unknown post id throws", () => { - expect(() => allocationFor(dataset(), "nope", "2026-09")).toThrow(/nope/); + test("the allocation steps up in the version's month", () => { + const fold = foldBalances(data, "2026-08"); + expect(figuresFor(fold, "p1", "2026-06").allocation).toBe(1000); + expect(figuresFor(fold, "p1", "2026-07").allocation).toBe(1500); + }); + + test("unspent budget carries across the change", () => { + const fold = foldBalances(data, "2026-08"); + // 1000 + 1000 = 2000 carried into July, + 1500 = 3500, + 1500 = 5000. + expect(figuresFor(fold, "p1", "2026-07").carriedIn).toBe(2000); + expect(figuresFor(fold, "p1", "2026-08").remaining).toBe(5000); + }); + + test("a version dated before the fold start is legal but inert", () => { + // The fold never evaluates months before its start, so such a version + // simply has no effect until the fold start moves earlier. + const early: Dataset = { + ...data, + posts: [ + post({ + rules: [ + { from: "2020-01", rule: pct(99) }, + { from: "2026-04", rule: pct(10) }, + ], + }), + ], + }; + const fold = foldBalances(early, "2026-06"); + expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000); + expect(fold.has("2020-01")).toBe(false); + }); + + test("an archived post still resolves its rules, because it still folds", () => { + const archived: Dataset = { + ...data, + posts: [post({ archived: true, rules: [{ from: "2026-05", rule: pct(10) }] })], + }; + const fold = foldBalances(archived, "2026-06"); + expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000); + expect(figuresFor(fold, "p1", "2026-06").carriedIn).toBe(1000); + }); + + test("months before the earliest version allocate nothing", () => { + const early: Dataset = { + ...data, + settings: { ...data.settings, foldStartMonth: "2026-02" }, + months: [ + { id: "2026-02", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} }, + ...data.months, + ], + }; + const fold = foldBalances(early, "2026-05"); + expect(figuresFor(fold, "p1", "2026-02").allocation).toBe(0); + expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000); + }); }); diff --git a/src/domain/allocation.ts b/src/domain/allocation.ts index d205e9a..0e2b186 100644 --- a/src/domain/allocation.ts +++ b/src/domain/allocation.ts @@ -1,5 +1,6 @@ import { toBase } from "./fx.ts"; import { roundMoney } from "./money.ts"; +import { compareMonths } from "./months.ts"; import type { Currency, Dataset, @@ -9,6 +10,7 @@ import type { Post, PostId, Rule, + RuleVersion, } from "./types.ts"; /** Resolves a rule to a base-currency amount. Percentages may exceed 100. */ @@ -24,8 +26,36 @@ export function resolveRule( return roundMoney((income * rule.percent) / 100, baseCurrency); } -export function ruleForPost(month: Month | undefined, post: Post): Rule { - return month?.ruleOverrides[post.id] ?? post.standingRule; +/** + * The version in effect for `monthId`: the last one whose `from` is at or + * before it, or null when the post was not yet budgeted. + * + * Scans rather than assuming sorted order. The array is kept sorted by + * `setRuleFrom`, but a hand-edited import must still resolve correctly. + */ +export function ruleAt(post: Post, monthId: MonthId): RuleVersion | null { + let effective: RuleVersion | null = null; + for (const version of post.rules) { + if (compareMonths(version.from, monthId) > 0) continue; + if (effective === null || compareMonths(version.from, effective.from) > 0) { + effective = version; + } + } + return effective; +} + +/** + * Precedence: a one-month override wins outright; otherwise the version in + * effect; otherwise nothing, which the caller renders as an allocation of zero. + */ +export function ruleForPost( + month: Month | undefined, + post: Post, + monthId: MonthId, +): Rule | null { + const override = month?.ruleOverrides[post.id]; + if (override) return override; + return ruleAt(post, monthId)?.rule ?? null; } export function isOverridden(month: Month | undefined, postId: PostId): boolean { @@ -46,8 +76,10 @@ export function allocationFor( const post = dataset.posts.find((p) => p.id === postId); if (!post) throw new Error(`Unknown post: ${postId}`); const month = dataset.months.find((m) => m.id === monthId); + const rule = ruleForPost(month, post, monthId); + if (rule === null) return 0; return resolveRule( - ruleForPost(month, post), + rule, incomeFor(dataset, monthId), dataset.settings.baseCurrency, dataset.fxRates, diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index e8b0df5..1ed332c 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -9,7 +9,7 @@ function post(id: string, fixed: number, archived = false): Post { order: 0, archived, currency: "DKK", - standingRule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } }, + rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } } }], }; } @@ -114,7 +114,7 @@ test("percentage allocations follow the month's income", () => { order: 0, archived: false, currency: "DKK", - standingRule: { kind: "percentOfIncome", percent: 10 }, + rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }], }, ], months: [ diff --git a/src/domain/seed.test.ts b/src/domain/seed.test.ts index 891eaa2..03bacfd 100644 --- a/src/domain/seed.test.ts +++ b/src/domain/seed.test.ts @@ -12,14 +12,11 @@ test("seeds exactly the three named posts, in order", () => { expect(data.posts.map((p) => p.order)).toEqual([0, 1, 2]); }); -test("seeded posts are active with a zero fixed standing rule", () => { +test("seeded posts are active and unbudgeted", () => { const data = createSeedDataset("2026-09"); for (const post of data.posts) { expect(post.archived).toBe(false); - expect(post.standingRule).toEqual({ - kind: "fixed", - amount: { amount: 0, currency: "DKK" }, - }); + expect(post.rules).toEqual([]); } }); diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 8c63473..5f32daa 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,6 +1,6 @@ import type { Dataset, MonthId, Post } from "./types.ts"; -export const SCHEMA_VERSION = 1; +export const SCHEMA_VERSION = 2; const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; @@ -19,7 +19,9 @@ export function createSeedDataset(startMonth: MonthId): Dataset { order, archived: false, currency: "DKK", - standingRule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } }, + // A new user has genuinely not budgeted anything yet. This is a different + // state from a rule of zero, and the app distinguishes them. + rules: [], })); return { diff --git a/src/domain/types.ts b/src/domain/types.ts index 93a7e5c..c929e42 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -28,6 +28,12 @@ export type Rule = | { kind: "fixed"; amount: Money } | { kind: "percentOfIncome"; percent: number }; +export interface RuleVersion { + /** The rule takes effect in this month and continues until the next version. */ + from: MonthId; + rule: Rule; +} + export interface Post { id: PostId; name: string; @@ -35,7 +41,12 @@ export interface Post { archived: boolean; /** Display currency for this post's own views only. All totals use base. */ currency: Currency; - standingRule: Rule; + /** + * The post's allocation rule over time, sorted ascending by `from`, at most + * one entry per month. Empty means the post has never been budgeted, and its + * allocation is zero — not the same as a rule of zero. + */ + rules: RuleVersion[]; } export interface Month { diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index 9158c30..e28c2f0 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -9,7 +9,7 @@ function post(id: string, order: number, percent: number, archived = false): Pos order, archived, currency: "DKK", - standingRule: { kind: "percentOfIncome", percent }, + rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent } }], }; } diff --git a/src/export/ods.test.ts b/src/export/ods.test.ts index f8aa687..d9af74c 100644 --- a/src/export/ods.test.ts +++ b/src/export/ods.test.ts @@ -8,7 +8,7 @@ import type { Dataset } from "../domain/types.ts"; function populated(): Dataset { const data = createSeedDataset("2026-01"); data.months[0]!.income = { amount: 20000, currency: "DKK" }; - data.posts[1]!.standingRule = { kind: "percentOfIncome", percent: 25 }; + data.posts[1]!.rules = [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 25 } }]; data.purchases.push({ id: "p1", date: "2026-01-15", diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index e46f142..2fcddb3 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -77,34 +77,16 @@ describe("months", () => { describe("posts", () => { test("addPost appends with the next order value", () => { const data = draft(); - const post = actions.addPost(data, "Travel", "EUR", { - kind: "percentOfIncome", - percent: 5, - }); + const post = actions.addPost(data, "Travel", "EUR"); expect(post.order).toBe(3); expect(post.archived).toBe(false); expect(data.posts).toHaveLength(4); }); - test("addPost rounds a fixed standing rule's amount and a percent's percent", () => { + test("addPost starts the post unbudgeted", () => { const data = draft(); - const fixedPost = actions.addPost(data, "Rent", "DKK", { - kind: "fixed", - amount: { amount: 12.345, currency: "DKK" }, - }); - expect(fixedPost.standingRule).toEqual({ - kind: "fixed", - amount: { amount: 12.35, currency: "DKK" }, - }); - - const percentPost = actions.addPost(data, "Travel", "EUR", { - kind: "percentOfIncome", - percent: 12.3456, - }); - expect(percentPost.standingRule).toEqual({ - kind: "percentOfIncome", - percent: 12.35, - }); + const post = actions.addPost(data, "Travel", "EUR"); + expect(post.rules).toEqual([]); }); test("setPostArchived toggles without deleting", () => { @@ -140,26 +122,6 @@ describe("posts", () => { expect(post.currency).toBe("DKK"); }); - test("updatePost rounds a fixed standing rule's amount and a percent's percent", () => { - const data = draft(); - const postId = data.posts[0]!.id; - actions.updatePost(data, postId, { - standingRule: { kind: "fixed", amount: { amount: 12.345, currency: "DKK" } }, - }); - expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({ - kind: "fixed", - amount: { amount: 12.35, currency: "DKK" }, - }); - - actions.updatePost(data, postId, { - standingRule: { kind: "percentOfIncome", percent: 12.3456 }, - }); - expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({ - kind: "percentOfIncome", - percent: 12.35, - }); - }); - test("an unknown post id throws", () => { expect(() => actions.updatePost(draft(), "ghost", { name: "x" })).toThrow(/ghost/); }); diff --git a/src/store/actions.ts b/src/store/actions.ts index 561fd7d..c1d93e6 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -102,19 +102,14 @@ export function setRuleOverride( month.ruleOverrides[postId] = roundRule(rule); } -export function addPost( - draft: Dataset, - name: string, - currency: Currency, - standingRule: Rule, -): Post { +export function addPost(draft: Dataset, name: string, currency: Currency): Post { const post: Post = { id: newId(), name, order: draft.posts.length, archived: false, currency, - standingRule: roundRule(standingRule), + rules: [], }; draft.posts.push(post); return post; @@ -127,9 +122,6 @@ export function updatePost( ): void { const post = requirePost(draft, postId); const resolved: Partial> = { ...changes }; - if (changes.standingRule) { - resolved.standingRule = roundRule(changes.standingRule); - } Object.assign(post, resolved); } diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx index 218bb28..16dd6cf 100644 --- a/src/ui/routes/settings/PostsSection.tsx +++ b/src/ui/routes/settings/PostsSection.tsx @@ -4,7 +4,9 @@ import { Input } from "@/components/ui/input"; import { useDataset } from "../../hooks/useDataset.ts"; import { useMutate } from "../../hooks/useMutate.ts"; import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts"; -import { CURRENCIES, type Currency, type Rule } from "../../../domain/types.ts"; +import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts"; +import { ruleAt } from "../../../domain/allocation.ts"; +import { currentMonth } from "../../../store/index.ts"; export function PostsSection() { const dataset = useDataset(); @@ -13,48 +15,19 @@ export function PostsSection() { const ordered = [...dataset.posts].sort((a, b) => a.order - b.order); const base = dataset.settings.baseCurrency; - function ruleEditor(postId: string, rule: Rule) { + function ruleSummary(post: Post) { + const effective = ruleAt(post, currentMonth); + if (!effective) { + return not budgeted; + } + const { rule } = effective; return ( -
- - { - const value = Number(event.target.value) || 0; - mutate((draft) => - updatePost(draft, postId, { - standingRule: - rule.kind === "fixed" - ? { kind: "fixed", amount: { ...rule.amount, amount: value } } - : { kind: "percentOfIncome", percent: value }, - }), - ); - }} - /> - - {rule.kind === "fixed" ? rule.amount.currency : "%"} - -
+ + {rule.kind === "fixed" + ? `${rule.amount.amount} ${rule.amount.currency}` + : `${rule.percent}% of income`} + from {effective.from} + ); } @@ -105,7 +78,7 @@ export function PostsSection() { ))} - {ruleEditor(post.id, post.standingRule)} + {ruleSummary(post)}