ARCHITECTURE, PRODUCT and TODO move out of the repository root, and
docs/superpowers/{specs,plans} flattens to docs/{specs,plans}:
"superpowers" names the tool that wrote those documents, not a kind of
document.
No content changes. This commit is the move, plus the roughly thirty
cross-references the move invalidated.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
47 KiB
Executable File
Time-Versioned Allocation Rules Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A post's allocation rule becomes a dated series — 10% from April, 15% from July — instead of one rule covering all of history.
Architecture: Post.standingRule is replaced by Post.rules: RuleVersion[], resolved by ruleAt(post, monthId) which returns the last version effective at or before that month. Per-month overrides survive as one-month exceptions and still win outright. A post with no applicable version allocates zero. allocationFor keeps its signature, so the balance fold, the view models and the ODS export are untouched.
Tech Stack: Bun (runtime, package manager, test runner), TypeScript strict, React 19, Tailwind 4, shadcn/ui. No new dependencies.
Spec: docs/specs/2026-09-02-versioned-allocation-rules-design.md
Global Constraints
Every task's requirements implicitly include this section.
- Bun only.
bun test,bun run,bunx. Never npm, jest, vitest, or vite. src/domain/stays pure: no React, no IndexedDB, no ambient clock. It must be importable with no DOM (src/domain/smoke.test.tsasserts this).- The rollover fold has exactly one implementation, in
src/domain/fold.ts. Nothing may recomputecarriedIn + allocation − charges. - Money is a float, rounded at every boundary via
roundMoney, and at thesrc/store/actions.tsmutation boundary.FxRate.baseUnitsPerOneis NEVER rounded — rates are stored at six decimals and are not money. - Over-budget is always permitted. Nothing may clamp or reject a value for magnitude; percentages above 100% stay legal.
- Posts are archived, never deleted. Archived posts still resolve rules, because they still fold.
MonthIdis"YYYY-MM".IsoDateis"YYYY-MM-DD"or"YYYY-MM".- Never read
event.target.valueinside amutate()callback. Capture it synchronously first;src/ui/eventCapture.test.tsenforces this. - UI adds no money rounding — the mutation boundary does it.
- Base path comes from
BUN_PUBLIC_BASE_PATH; never hardcode a subpath.
File Structure
| File | Responsibility |
|---|---|
src/domain/types.ts |
RuleVersion; Post.rules replaces standingRule |
src/domain/allocation.ts |
ruleAt, and the three-level precedence in allocationFor |
src/domain/seed.ts |
seeded posts start unbudgeted; SCHEMA_VERSION 2 |
src/store/migrations.ts |
the v1 → v2 shape change |
src/store/actions.ts |
setRuleFrom, removeRuleFrom; addPost no longer takes a rule |
src/export/json.ts |
validate rules; reject duplicate from months |
src/ui/routes/settings/PostsSection.tsx |
the rule history editor |
src/ui/components/PostTable.tsx |
version-starts-here marker |
src/ui/routes/MonthRoute.tsx |
change-from-this-month control |
Task 1: The dated-rule model
This task is deliberately large because it is atomic. Replacing
Post.standingRule with Post.rules is a breaking type change: TypeScript will
not compile a partial version, so everything referencing the old field moves
together. The settings UI gets only a minimal adaptation here — Task 4 builds
the real history editor.
Files:
- Modify:
src/domain/types.ts,src/domain/allocation.ts,src/domain/seed.ts,src/store/actions.ts,src/ui/routes/settings/PostsSection.tsx - Test:
src/domain/allocation.test.ts
Interfaces:
-
Consumes:
Rule,MonthId,Post,Month,Datasetfrom./types.ts;compareMonthsfrom./months.ts;resolveRule,incomeFor,isOverridden(existing, unchanged) from./allocation.ts. -
Produces:
interface RuleVersion { from: MonthId; rule: Rule }(intypes.ts)Post.rules: RuleVersion[]replacingPost.standingRuleruleAt(post: Post, monthId: MonthId): RuleVersion | nullruleForPost(month: Month | undefined, post: Post, monthId: MonthId): Rule | null— note the added third parameterallocationFor(dataset, postId, monthId): number— signature UNCHANGEDSCHEMA_VERSION = 2addPost(draft, name, currency): Post— the rule parameter is GONE
-
Step 1: Write the failing tests
Replace the whole of src/domain/allocation.test.ts with this. The existing
fixtures build posts with standingRule and will not compile otherwise.
import { test, expect, describe } from "bun:test";
import {
resolveRule,
ruleAt,
ruleForPost,
isOverridden,
allocationFor,
incomeFor,
} from "./allocation.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> = {}): Post {
return {
id: "p1",
name: "Food",
order: 0,
archived: false,
currency: "DKK",
rules: [{ from: "2026-01", rule: fixed(400) }],
...overrides,
};
}
function dataset(overrides: Partial<Dataset> = {}): Dataset {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
fxRates: rates,
posts: [post()],
months: [
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
],
purchases: [],
...overrides,
};
}
describe("resolveRule", () => {
test("a fixed rule in base currency is its amount", () => {
expect(resolveRule(fixed(400), 20000, "DKK", rates)).toBe(400);
});
test("a fixed rule in a foreign currency converts to base", () => {
expect(
resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates),
).toBe(373);
});
test("a percentage rule resolves against income", () => {
expect(resolveRule(pct(25), 20000, "DKK", rates)).toBe(5000);
});
test("a percentage above 100 is permitted", () => {
expect(resolveRule(pct(150), 1000, "DKK", rates)).toBe(1500);
});
});
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-01",
income: { amount: 20000, currency: "DKK" },
ruleOverrides: { p1: fixed(999) },
},
],
});
expect(allocationFor(data, "p1", "2026-01")).toBe(999);
expect(isOverridden(data.months[0], "p1")).toBe(true);
});
test("without an override the effective version applies", () => {
expect(allocationFor(dataset(), "p1", "2026-01")).toBe(400);
});
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-01")).toBe(20000);
});
test("a month with no record has zero income", () => {
expect(incomeFor(dataset(), "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("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);
});
});
- Step 2: Run the tests and confirm they fail
Run: bun test src/domain/allocation.test.ts
Expected: FAIL — ruleAt is not exported, and Post has no rules property.
- Step 3: Add
RuleVersionand swap the field onPost
In src/domain/types.ts, add after the Rule union:
export interface RuleVersion {
/** The rule takes effect in this month and continues until the next version. */
from: MonthId;
rule: Rule;
}
Then in interface Post, replace the line standingRule: Rule; with:
/**
* 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[];
- Step 4: Implement
ruleAtand the new precedence
In src/domain/allocation.ts, add compareMonths to the existing import from
./months.ts (do not add a second import statement), and add RuleVersion to
the existing type import from ./types.ts. Then replace ruleForPost with:
/**
* 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;
}
Then change the body of allocationFor so a missing rule means zero:
export function allocationFor(
dataset: Dataset,
postId: PostId,
monthId: MonthId,
): number {
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(
rule,
incomeFor(dataset, monthId),
dataset.settings.baseCurrency,
dataset.fxRates,
);
}
isOverridden and resolveRule are unchanged.
- Step 5: Update the seed and bump the schema version
In src/domain/seed.ts, change SCHEMA_VERSION to 2, and in the seeded post
replace the standingRule: line with:
// 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: [],
- Step 6: Update the actions that referenced the old field
In src/store/actions.ts, change addPost so it no longer takes a rule — a new
post starts unbudgeted, and a rule is added with setRuleFrom (Task 3):
export function addPost(draft: Dataset, name: string, currency: Currency): Post {
const post: Post = {
id: newId(),
name,
order: draft.posts.length,
archived: false,
currency,
rules: [],
};
draft.posts.push(post);
return post;
}
In updatePost, delete the if (changes.standingRule) { ... } block entirely —
rules are changed only through setRuleFrom/removeRuleFrom. Keep the rest of
updatePost exactly as it is, including its rounding of other fields.
- Step 7: Minimal settings adaptation so the build compiles
In src/ui/routes/settings/PostsSection.tsx, the rule editor currently reads and
writes post.standingRule. Replace the whole ruleEditor helper and its call
site with a read-only summary for now; Task 4 replaces this with the real
history editor.
Add to the imports:
import { ruleAt } from "../../../domain/allocation.ts";
import { currentMonth } from "../../../store/index.ts";
Replace the ruleEditor function with:
function ruleSummary(post: Post) {
const effective = ruleAt(post, currentMonth);
if (!effective) {
return <span className="text-xs text-muted-foreground">not budgeted</span>;
}
const { rule } = effective;
return (
<span className="text-xs">
{rule.kind === "fixed"
? `${rule.amount.amount} ${rule.amount.currency}`
: `${rule.percent}% of income`}
<span className="ml-1 text-muted-foreground">from {effective.from}</span>
</span>
);
}
Change the cell that rendered it from {ruleEditor(post.id, post.standingRule)}
to {ruleSummary(post)}, and fix the "Add post" button's addPost(...) call to
drop its rule argument: addPost(draft, newName.trim(), base).
Add Post to the existing type import from ../../../domain/types.ts.
- Step 8: Run the tests and the typechecker
Run: bun test src/domain/allocation.test.ts
Expected: PASS, 26 tests.
Run: bunx tsc --noEmit
Expected: clean. If anything still references standingRule, it appears here.
Run: bun test
Expected: some OTHER suites fail — seed.test.ts, actions.test.ts,
json.test.ts and views.test.ts build posts with standingRule. Fix each
fixture by replacing standingRule: X with rules: [{ from: "2026-01", rule: X }],
choosing a from at or before the earliest month that suite uses, so the
resolved allocations stay what those tests already assert. Do NOT change any
assertion values: this task must not move a single number.
Run: bun test again.
Expected: PASS, whole suite green.
- Step 9: Commit
git add -A
git commit -m "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."
Task 2: The v1 to v2 migration
Files:
- Modify:
src/store/migrations.ts - Test:
src/store/migrations.test.ts
Interfaces:
-
Consumes:
SCHEMA_VERSION(now 2) from../domain/seed.ts;Dataset,Rule,RuleVersionfrom../domain/types.ts;allocationForfrom../domain/allocation.ts(test only). -
Produces:
MIGRATIONS[1]— the v1 → v2 step.migrateitself is unchanged. -
Step 1: Write the failing tests
Append to src/store/migrations.test.ts:
import { allocationFor } from "../domain/allocation.ts";
/** A v1 dataset, in the shape that shipped before rules were versioned. */
function v1Dataset() {
return {
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
fxRates: [],
posts: [
{
id: "food",
name: "Food",
order: 0,
archived: false,
currency: "DKK",
standingRule: { kind: "percentOfIncome", percent: 20 },
},
{
id: "games",
name: "Video Games",
order: 1,
archived: false,
currency: "DKK",
standingRule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } },
},
],
months: [
{ id: "2026-01", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
{
id: "2026-02",
income: { amount: 12000, currency: "DKK" },
ruleOverrides: { games: { kind: "fixed", amount: { amount: 900, currency: "DKK" } } },
},
],
purchases: [],
};
}
describe("v1 -> v2: rules become a dated series", () => {
test("each standingRule becomes one version starting at the fold start", () => {
const migrated = migrate(v1Dataset());
expect(migrated.settings.schemaVersion).toBe(2);
expect(migrated.posts[0]!.rules).toEqual([
{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 20 } },
]);
expect(migrated.posts[1]!.rules).toEqual([
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } } },
]);
});
test("the old field is gone afterwards", () => {
const migrated = migrate(v1Dataset()) as unknown as Record<string, any>;
expect("standingRule" in (migrated.posts as any[])[0]).toBe(false);
});
test("per-month overrides are left untouched", () => {
const migrated = migrate(v1Dataset());
expect(migrated.months[1]!.ruleOverrides.games).toEqual({
kind: "fixed",
amount: { amount: 900, currency: "DKK" },
});
});
// The property that matters: this is a shape change, not a recalculation.
// A shape-only test would pass while every balance quietly moved.
test("NO allocation changes: every post in every month resolves identically", () => {
const before = v1Dataset();
const migrated = migrate(v1Dataset());
// Resolve the v1 way by hand, since allocationFor now speaks v2 only.
const v1Allocation = (postId: string, monthId: string) => {
const post = before.posts.find((p) => p.id === postId)!;
const month = before.months.find((m) => m.id === monthId);
const rule = (month?.ruleOverrides as any)?.[postId] ?? post.standingRule;
const income = before.months.find((m) => m.id === monthId)?.income.amount ?? 0;
return rule.kind === "fixed"
? rule.amount.amount
: Number(((income * rule.percent) / 100).toFixed(2));
};
for (const postId of ["food", "games"]) {
for (const monthId of ["2026-01", "2026-02"]) {
expect(allocationFor(migrated, postId, monthId)).toBe(v1Allocation(postId, monthId));
}
}
});
test("a v1 post with no posts at all migrates without throwing", () => {
const empty = { ...v1Dataset(), posts: [] };
expect(migrate(empty).posts).toEqual([]);
});
});
- Step 2: Run and confirm failure
Run: bun test src/store/migrations.test.ts
Expected: FAIL — migrate throws "No migration from schema 1 to 2", because
MIGRATIONS is still empty.
- Step 3: Implement the migration
In src/store/migrations.ts, replace the empty MIGRATIONS array with:
const MIGRATIONS: Array<(data: any) => any> = [
// index 0: there is no version 0 in the wild.
undefined as unknown as (data: any) => any,
// 1 -> 2: a post's single `standingRule` becomes a one-entry dated series,
// effective from the fold start. Behaviour-preserving by construction: the
// standing rule already applied from the fold start onward, so every month
// resolves to exactly the allocation it did before.
(data: any) => ({
...data,
settings: { ...data.settings, schemaVersion: 2 },
posts: (data.posts ?? []).map((post: any) => {
const { standingRule, ...rest } = post;
return {
...rest,
rules:
standingRule === undefined
? []
: [{ from: data.settings.foldStartMonth, rule: standingRule }],
};
}),
}),
];
Note the index-0 hole: MIGRATIONS[v] is looked up by the version being
migrated FROM, so the v1 → v2 step must sit at index 1.
- Step 4: Run the tests
Run: bun test src/store/migrations.test.ts
Expected: PASS, 9 tests.
Run: bun test
Expected: whole suite green.
- Step 5: Commit
git add src/store/migrations.ts src/store/migrations.test.ts
git commit -m "feat(store): migrate allocation rules to a dated series (v1 -> v2)
A pure shape change: standingRule becomes one version effective from the fold
start, which is where it already applied from. Asserted behaviour-preserving —
every post in every month resolves to the same allocation before and after, so
no balance moves."
Task 3: Actions for editing the series
Files:
- Modify:
src/store/actions.ts - Test:
src/store/actions.test.ts
Interfaces:
-
Consumes:
requirePost,roundRule(both already private inactions.ts);compareMonthsfrom../domain/months.ts;RuleVersion,Rule,MonthId,PostId,Datasetfrom../domain/types.ts. -
Produces:
setRuleFrom(draft: Dataset, postId: PostId, from: MonthId, rule: Rule): voidremoveRuleFrom(draft: Dataset, postId: PostId, from: MonthId): void
-
Step 1: Write the failing tests
Append to src/store/actions.test.ts:
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(/ghost/);
expect(() => actions.removeRuleFrom(draft(), "ghost", "2026-04")).toThrow(/ghost/);
});
test("addPost creates an unbudgeted post", () => {
const data = draft();
const post = actions.addPost(data, "Travel", "EUR");
expect(post.rules).toEqual([]);
});
});
- Step 2: Run and confirm failure
Run: bun test src/store/actions.test.ts
Expected: FAIL — setRuleFrom is not exported.
- Step 3: Implement both actions
In src/store/actions.ts, add compareMonths to the existing import from
../domain/months.ts and RuleVersion to the existing type import from
../domain/types.ts. Then add:
/**
* 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".
*/
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);
}
- Step 4: Run the tests
Run: bun test src/store/actions.test.ts
Expected: PASS, including the 9 new tests.
Run: bun test && bunx tsc --noEmit
Expected: whole suite green, types clean.
- Step 5: Commit
git add src/store/actions.ts src/store/actions.test.ts
git commit -m "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."
Task 4: Import validation for the series
Files:
- Modify:
src/export/json.ts - Test:
src/export/json.test.ts
Interfaces:
-
Consumes:
MONTH_ID(already defined injson.ts),ImportValidationError. -
Produces: no new exports;
parseDatasetJsongains validation. -
Step 1: Write the failing tests
Append to src/export/json.test.ts:
describe("rule versions on import", () => {
const pct = (percent: number) => ({ kind: "percentOfIncome" as const, percent });
test("accepts a well-formed series", () => {
const data = populated();
data.posts[0]!.rules = [
{ from: "2026-01", rule: pct(10) },
{ from: "2026-07", rule: pct(15) },
];
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
});
test("accepts an empty series — an unbudgeted post is legal", () => {
const data = populated();
data.posts[0]!.rules = [];
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
});
test("rejects a post whose rules is missing or not an array", () => {
const data = populated() as any;
delete data.posts[0].rules;
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/rules/);
});
test("rejects a malformed from month", () => {
const data = populated();
data.posts[0]!.rules = [{ from: "2026-1", rule: pct(10) }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-1/);
});
test("rejects an out-of-range from month", () => {
const data = populated();
data.posts[0]!.rules = [{ from: "2026-13", rule: pct(10) }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13/);
});
test("rejects two versions sharing a month, which would be ambiguous", () => {
const data = populated();
data.posts[0]!.rules = [
{ from: "2026-04", rule: pct(10) },
{ from: "2026-04", rule: pct(15) },
];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-04/);
});
test("does NOT reject a percentage above 100", () => {
const data = populated();
data.posts[0]!.rules = [{ from: "2026-01", rule: pct(150) }];
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
});
});
You will also need to update populated() in that file so its posts carry
rules rather than standingRule, if Task 1 has not already done so.
- Step 2: Run and confirm failure
Run: bun test src/export/json.test.ts
Expected: FAIL — a missing rules array and a duplicate month both import
cleanly today.
- Step 3: Implement the validation
In src/export/json.ts, inside the existing for (const post of dataset.posts)
loop, after the requireCurrency(post.currency, ...) line, add:
const rules = (post as unknown as Record<string, unknown>).rules;
if (!Array.isArray(rules)) {
throw new ImportValidationError(
`Post "${post.name}" is missing its rules array`,
);
}
const seenFrom = new Set<string>();
for (const version of rules as Array<{ from: string }>) {
if (!MONTH_ID.test(version.from)) {
throw new ImportValidationError(
`Post "${post.name}" has a rule with an invalid start month "${version.from}"`,
);
}
if (seenFrom.has(version.from)) {
throw new ImportValidationError(
`Post "${post.name}" has two rules starting in "${version.from}"; the effective rule would be ambiguous`,
);
}
seenFrom.add(version.from);
}
Deliberately absent: any check on the rule's value. A percentage above 100 and a negative fixed amount are both legal, because over-budget is always permitted.
- Step 4: Run the tests
Run: bun test src/export/json.test.ts
Expected: PASS.
Run: bun test && bunx tsc --noEmit
Expected: whole suite green, types clean.
- Step 5: Commit
git add src/export/json.ts src/export/json.test.ts
git commit -m "feat(export): validate rule versions on import
Rejects a missing rules array, a malformed or out-of-range start month, and two
versions sharing a month — which would make the effective rule ambiguous — at
the import boundary rather than letting the fold resolve something arbitrary."
Task 5: The rule history editor in Settings
Files:
- Create:
src/ui/components/RuleHistory.tsx - Modify:
src/ui/routes/settings/PostsSection.tsx
Interfaces:
- Consumes:
setRuleFrom,removeRuleFromfrom../../store/actions.ts;ruleAtfrom../../domain/allocation.ts;useMutate;currentMonthfrom../../store/index.ts;Post,Rule,MonthIdfrom../../domain/types.ts. - Produces:
RuleHistorycomponent, props{ post: Post }.
There is no failing-test step here: this renders already-tested pure functions, and asserting markup needs a DOM this project registers only per-file for the one test that requires it. The verification burden shifts to reading the code against the actions it calls.
- Step 1: Create the history editor
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useMutate } from "../hooks/useMutate.ts";
import { removeRuleFrom, setRuleFrom } from "../../store/actions.ts";
import { currentMonth } from "../../store/index.ts";
import type { Post, Rule } from "../../domain/types.ts";
interface Props {
post: Post;
}
/**
* A post's allocation over time. Each row is "from this month, this rule",
* effective until the next row takes over. Adding a row for a month that
* already has one replaces it — `setRuleFrom` enforces that, and the form says
* so rather than letting it look like an append.
*/
export function RuleHistory({ post }: Props) {
const { mutate } = useMutate();
const [from, setFrom] = useState<string>(currentMonth);
const [kind, setKind] = useState<Rule["kind"]>("percentOfIncome");
const [value, setValue] = useState<number>(0);
const replaces = post.rules.some((v) => v.from === from);
function add() {
const rule: Rule =
kind === "fixed"
? { kind: "fixed", amount: { amount: value, currency: post.currency } }
: { kind: "percentOfIncome", percent: value };
mutate((draft) => setRuleFrom(draft, post.id, from, rule));
}
return (
<div className="space-y-2 rounded border p-3">
{post.rules.length === 0 ? (
<p className="text-xs text-muted-foreground">
Not budgeted. Until a rule starts, this post is allocated nothing —
spending still records, and shows as overspend.
</p>
) : (
<table className="w-full text-xs">
<thead className="text-left text-muted-foreground">
<tr>
<th className="py-1">From</th>
<th className="py-1">Rule</th>
<th />
</tr>
</thead>
<tbody>
{post.rules.map((version) => (
<tr key={version.from} className="border-t">
<td className="py-1 font-money">{version.from}</td>
<td className="py-1">
{version.rule.kind === "fixed"
? `${version.rule.amount.amount} ${version.rule.amount.currency}`
: `${version.rule.percent}% of income`}
</td>
<td className="py-1 text-right">
<Button
size="sm"
variant="ghost"
onClick={() => mutate((draft) => removeRuleFrom(draft, post.id, version.from))}
>
remove
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
<div className="flex flex-wrap items-end gap-2">
<Input
className="font-money h-8 w-28"
placeholder="YYYY-MM"
value={from}
onChange={(event) => {
const next = event.target.value;
setFrom(next);
}}
/>
<select
className="h-8 rounded border bg-background px-1 text-xs"
value={kind}
onChange={(event) => {
const next = event.target.value as Rule["kind"];
setKind(next);
}}
>
<option value="percentOfIncome">% of income</option>
<option value="fixed">fixed amount</option>
</select>
<Input
className="font-money h-8 w-24"
type="number"
step="0.01"
value={value}
onChange={(event) => {
const next = Number(event.target.value) || 0;
setValue(next);
}}
/>
<Button size="sm" variant="outline" onClick={add}>
{replaces ? "Replace" : "Add"}
</Button>
{replaces && (
<span className="text-xs text-muted-foreground">
{from} already has a rule; this replaces it.
</span>
)}
</div>
</div>
);
}
Note every onChange captures event.target.value into a local const before
using it. src/ui/eventCapture.test.ts fails the build otherwise, and the bug
it guards froze an input at zero once already.
- Step 2: Mount it in Settings
In src/ui/routes/settings/PostsSection.tsx, import the component:
import { RuleHistory } from "../../components/RuleHistory.tsx";
Add a per-post expansion state near the other hooks:
const [openHistory, setOpenHistory] = useState<string | null>(null);
Change the rule cell so the summary from Task 1 becomes a toggle:
<td className="py-2">
<button
type="button"
className="underline decoration-dotted"
onClick={() => setOpenHistory((id) => (id === post.id ? null : post.id))}
>
{ruleSummary(post)}
</button>
</td>
And directly after each post's <tr>, render the expanded editor:
{openHistory === post.id && (
<tr>
<td colSpan={4} className="pb-3">
<RuleHistory post={post} />
</td>
</tr>
)}
- Step 3: Verify
Run: bun test && bunx tsc --noEmit
Expected: whole suite green, types clean. In particular
src/ui/eventCapture.test.ts must pass.
Run: bun run build
Expected: succeeds.
Start the dev server, curl /settings to confirm it serves, then STOP it:
bun --hot src/index.ts # curl -s -o /dev/null -w '%{http_code}' localhost:3000/settings
You cannot verify the editor visually — there is no browser here. Say so in your report; do not claim otherwise.
- Step 4: Commit
git add -A
git commit -m "feat(ui): add the rule history editor to Settings
Each post's allocation over time, as rows of 'from this month, this rule'.
Adding a rule for a month that already has one replaces it, and the form says
so rather than looking like an append. An unbudgeted post says so explicitly,
since that is a different state from a rule of zero."
Task 6: Change from this month, in the month view
Files:
- Modify:
src/ui/components/PostTable.tsx,src/ui/routes/MonthRoute.tsx
Interfaces:
-
Consumes:
ruleAtfrom../../domain/allocation.ts;setRuleFromfrom../../store/actions.ts;useMutate;MonthPostRowfrom../../domain/views.ts. -
Produces: no new exports.
-
Step 1: Mark the month a version begins
PostTable already receives rows and shows an overridden badge. Add a
second, distinct marker so a step in the numbers has a visible cause.
In src/ui/components/PostTable.tsx, add to the imports:
import { ruleAt } from "../../domain/allocation.ts";
Inside the row map, after the existing overridden badge, add:
{ruleAt(post, monthId)?.from === monthId && (
<Badge
variant="outline"
className="ml-2"
title="This post's allocation rule changes from this month"
>
rule changes here
</Badge>
)}
A month can show both badges: a version starting in a month the user also overrode is unusual but legal, and hiding either would obscure why the number is what it is. The override still wins the allocation.
- Step 2: Add the change-from-here control
In src/ui/routes/MonthRoute.tsx, add the imports:
import { ruleAt } from "../../domain/allocation.ts";
import { setRuleFrom } from "../../store/actions.ts";
Add local state for the inline editor near the other hooks:
const [changingRuleFor, setChangingRuleFor] = useState<string | null>(null);
useState is already imported in this file if the mode toggle exists; add it to
the existing React import otherwise.
Then render the control beneath the post table:
{changingRuleFor && (() => {
const post = dataset.posts.find((p) => p.id === changingRuleFor);
if (!post) return null;
const effective = ruleAt(post, monthId)?.rule;
return (
<RuleFromMonth
post={post}
monthId={monthId}
initial={effective}
onDone={() => setChangingRuleFor(null)}
/>
);
})()}
- Step 3: Write the inline control
Add this component at the bottom of src/ui/routes/MonthRoute.tsx:
function RuleFromMonth({
post,
monthId,
initial,
onDone,
}: {
post: Post;
monthId: MonthId;
initial: Rule | undefined;
onDone: () => void;
}) {
const { mutate } = useMutate();
// Pre-filled with what is currently in effect, so nudging 10% to 15% is an
// edit rather than re-entry. Empty when the post has no rule yet.
const [kind, setKind] = useState<Rule["kind"]>(initial?.kind ?? "percentOfIncome");
const [value, setValue] = useState<number>(
initial === undefined ? 0 : initial.kind === "fixed" ? initial.amount.amount : initial.percent,
);
function apply() {
const rule: Rule =
kind === "fixed"
? { kind: "fixed", amount: { amount: value, currency: post.currency } }
: { kind: "percentOfIncome", percent: value };
mutate((draft) => setRuleFrom(draft, post.id, monthId, rule));
onDone();
}
return (
<div className="flex flex-wrap items-end gap-2 rounded border p-3 text-sm">
<span>
{post.name}: allocate from <span className="font-money">{monthId}</span> onward
</span>
<select
className="h-8 rounded border bg-background px-1 text-xs"
value={kind}
onChange={(event) => {
const next = event.target.value as Rule["kind"];
setKind(next);
}}
>
<option value="percentOfIncome">% of income</option>
<option value="fixed">fixed amount</option>
</select>
<Input
className="font-money h-8 w-24"
type="number"
step="0.01"
value={value}
onChange={(event) => {
const next = Number(event.target.value) || 0;
setValue(next);
}}
/>
<Button size="sm" onClick={apply}>
Apply
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
Cancel
</Button>
</div>
);
}
Add Post, Rule and MonthId to the existing type import from
../../domain/types.ts, and useState to the existing React import.
- Step 4: Give each post row a way to open it
In src/ui/components/PostTable.tsx, add an optional prop and a control:
interface Props {
monthId: MonthId;
baseCurrency: Currency;
rows: MonthPostRow[];
onChangeRule?: (postId: string) => void;
}
In the row, after the badges:
{onChangeRule && (
<button
type="button"
className="ml-2 text-xs text-muted-foreground underline decoration-dotted"
onClick={() => onChangeRule(post.id)}
>
change from here
</button>
)}
And pass it from MonthRoute:
<PostTable
monthId={monthId}
baseCurrency={base}
rows={view.rows}
onChangeRule={setChangingRuleFor}
/>
- Step 5: Verify
Run: bun test && bunx tsc --noEmit
Expected: whole suite green, types clean, eventCapture guard passing.
Run: bun run build
Expected: succeeds.
Then exercise the whole feature end to end with a script, since the UI itself cannot be clicked here:
bun -e '
import { createSeedDataset } from "./src/domain/seed.ts";
import { setRuleFrom } from "./src/store/actions.ts";
import { foldBalances, figuresFor } from "./src/domain/fold.ts";
const d = createSeedDataset("2026-05");
const food = d.posts[1].id;
for (const m of ["2026-05","2026-06","2026-07","2026-08"])
if (!d.months.find(x=>x.id===m)) d.months.push({ id:m, income:{amount:10000,currency:"DKK"}, ruleOverrides:{} });
d.months[0].income = { amount: 10000, currency: "DKK" };
setRuleFrom(d, food, "2026-05", { kind:"percentOfIncome", percent:10 });
setRuleFrom(d, food, "2026-07", { kind:"percentOfIncome", percent:15 });
const fold = foldBalances(d, "2026-08");
for (const m of ["2026-05","2026-06","2026-07","2026-08"])
console.log(m, "allocation", figuresFor(fold, food, m).allocation, "remaining", figuresFor(fold, food, m).remaining);
'
Expected: allocations 1000, 1000, 1500, 1500 and a remaining that accumulates 1000, 2000, 3500, 5000.
Report plainly that the two editing surfaces have not been seen in a browser.
- Step 6: Commit
git add -A
git commit -m "feat(ui): change a post's rule from the month you are looking at
The natural moment to decide '15% from July' is while looking at July. The
control writes the same setRuleFrom action the history editor uses, so the two
surfaces cannot drift apart. A month where a version begins gets its own badge,
distinct from a one-month override, so a step in the numbers has a visible
cause; a month can carry both."