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)}