Files
budget2.0/docs/plans/2026-09-02-versioned-allocation-rules.md
T
Lucas WintherandClaude Opus 5 d83edccc79 docs: move the long-form docs into docs/, without the superpowers nesting
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]>
2026-09-02 21:56:59 +02:00

47 KiB
Executable File
Raw Blame History

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.ts asserts this).
  • The rollover fold has exactly one implementation, in src/domain/fold.ts. Nothing may recompute carriedIn + allocation − charges.
  • Money is a float, rounded at every boundary via roundMoney, and at the src/store/actions.ts mutation boundary. FxRate.baseUnitsPerOne is 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.
  • MonthId is "YYYY-MM". IsoDate is "YYYY-MM-DD" or "YYYY-MM".
  • Never read event.target.value inside a mutate() callback. Capture it synchronously first; src/ui/eventCapture.test.ts enforces 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, Dataset from ./types.ts; compareMonths from ./months.ts; resolveRule, incomeFor, isOverridden (existing, unchanged) from ./allocation.ts.

  • Produces:

    • interface RuleVersion { from: MonthId; rule: Rule } (in types.ts)
    • Post.rules: RuleVersion[] replacing Post.standingRule
    • ruleAt(post: Post, monthId: MonthId): RuleVersion | null
    • ruleForPost(month: Month | undefined, post: Post, monthId: MonthId): Rule | null — note the added third parameter
    • allocationFor(dataset, postId, monthId): number — signature UNCHANGED
    • SCHEMA_VERSION = 2
    • addPost(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 RuleVersion and swap the field on Post

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 ruleAt and 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, RuleVersion from ../domain/types.ts; allocationFor from ../domain/allocation.ts (test only).

  • Produces: MIGRATIONS[1] — the v1 → v2 step. migrate itself 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 in actions.ts); compareMonths from ../domain/months.ts; RuleVersion, Rule, MonthId, PostId, Dataset from ../domain/types.ts.

  • Produces:

    • setRuleFrom(draft: Dataset, postId: PostId, from: MonthId, rule: Rule): void
    • removeRuleFrom(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 in json.ts), ImportValidationError.

  • Produces: no new exports; parseDatasetJson gains 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, removeRuleFrom from ../../store/actions.ts; ruleAt from ../../domain/allocation.ts; useMutate; currentMonth from ../../store/index.ts; Post, Rule, MonthId from ../../domain/types.ts.
  • Produces: RuleHistory component, 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: ruleAt from ../../domain/allocation.ts; setRuleFrom from ../../store/actions.ts; useMutate; MonthPostRow from ../../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."