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]>
9.5 KiB
Executable File
Time-versioned allocation rules — Design
Date: 2026-09-02
Status: Awaiting review
Extends 2026-09-01-budget-app-design.md. Every invariant there still holds,
with one deliberate replacement noted below.
Purpose
A post's allocation changes over time: Food is 10% from April, then 15% from
July. Today a post has exactly one standingRule covering all of history, plus
a per-month override. Expressing "15% from July onward" currently means editing
every month individually, and the fact that it was a deliberate change is lost.
Rules become a dated series. A version takes effect in its month and continues until the next version replaces it.
This model was offered during the original design and declined in favour of the simpler standing rule. It is being adopted now because using the app showed the simpler model does not survive contact with a budget that evolves.
Non-goals
- Versioning anything else. Post name, currency and archived status stay single-valued. Only the allocation rule is time-varying.
- Retroactive recalculation prompts. Adding a version dated in the past changes past allocations, and therefore every later balance, because the rollover fold is a left fold. That is correct and intended; the app does not warn about it beyond showing the new numbers.
- Reworking the fold.
allocationForkeeps its signature; only its internals change.
Decisions and why
Dated series AND per-month overrides both survive
They mean different things, and collapsing them loses information:
- A version is a permanent change from month X onward: "Food is 15% from July."
- An override is a one-month exception that leaves the series intact: "December only, 3000 for Christmas."
Expressing a one-month exception as versions needs two entries — change, then change back — and forgetting the second silently makes an exception permanent.
No rule yet means an allocation of zero
If a post's earliest version starts in 2026-04, its allocation for every month before that is 0. The post genuinely had no budget then. Spending in those months still records and shows as overspend against a zero envelope, so nothing is concealed.
Rejected: applying the earliest rule backwards. It invents budget for months the user never budgeted, and the rollover fold would then carry that fictional surplus forward into real months.
The series lives on the post
Post.rules rather than a top-level Dataset.ruleVersions collection. The
fold reads a post's rule for every post in every month, so a top-level
collection would mean filtering by postId on the hottest path in the app, and
would separate a rule from the thing it governs when archiving or exporting.
Rejected: keeping standingRule and adding a map of later changes. It cannot
express "no rule yet" — a standing rule always exists — so it contradicts the
zero-before-start decision, and leaves two concepts describing one thing.
Domain model
interface RuleVersion {
/** The rule takes effect in this month and continues until the next version. */
from: MonthId;
rule: Rule;
}
interface Post {
// ...unchanged...
rules: RuleVersion[];
}
Post.standingRule is removed, replaced by rules.
Invariants
rulesis sorted ascending byfrom.- At most one version per month. Setting a rule for a month that already has
one replaces it; two versions sharing a
fromwould make the effective rule ambiguous. - An empty
rulesarray is legal and means the post has never been budgeted. - A
fromearlier thansettings.foldStartMonthis 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.
A deliberate difference between new and migrated data. Seeded posts start
with rules: [] — a new user has genuinely not budgeted anything yet. Migrated
posts get one version carrying their existing rule, which for untouched seed
data is a zero fixed amount. So a fresh install shows "not budgeted" where a
migrated install shows an explicit 0. Those were indistinguishable under the old
model and are now different states; keeping them distinct is correct, because a
migrated user really did have a rule and a new user really does not.
Resolution
ruleAt(post, monthId): RuleVersion | null returns the last version whose
from <= monthId, or null when none applies.
allocationFor resolves in strict precedence:
month.ruleOverrides[postId]— one-month exception, wins outrightruleAt(post, monthId)— the effective version- neither -> 0
allocationFor keeps its current signature, so foldBalances, the view models
and the ODS export are untouched.
isOverridden keeps its current meaning — an explicit one-month exception — and
continues to drive the month view's badge.
Migration: schemaVersion 1 -> 2
{ standingRule } -> { rules: [{ from: settings.foldStartMonth, rule: standingRule }] }
This is the project's first real migration. It is a pure shape change with no arithmetic, and it is behaviour-preserving by construction: today's standing rule already applies from the fold start onward, so every month resolves to the allocation it resolved to before.
Existing Month.ruleOverrides are untouched.
The migration runs in the browser against the user's only copy of their data.
Two things contain the risk: migrate() already refuses to open data written by
a newer build, so a half-upgraded state cannot silently corrupt; and the
behaviour-preserving property is asserted by test rather than assumed.
UI
Two editing surfaces, both writing through the same setRuleFrom action, so
they cannot drift out of step with each other.
Settings — the history editor
Each post row shows the rule in effect for the current calendar month
(Settings has no month context of its own), with a control
opening a chronological editor: a list of from month + rule, with add and
remove. A post with no rules shows "not budgeted" rather than a zero rule, since
those are different states.
Adding a version for a month that already has one replaces it, and the editor says so rather than silently keeping the last write.
Month view — change from here
Each post row gains a control that creates a version starting that month,
because the natural moment to decide "15% from July" is while looking at July.
It writes the same setRuleFrom action the history editor uses.
It opens pre-filled with the rule currently in effect for that month, so the common act of nudging 10% to 15% is an edit rather than re-entry. If the post has no rule yet, it opens empty.
The month view distinguishes the two kinds of change: an override keeps its existing badge; a month where a version begins is marked separately, so a step in the numbers has a visible cause. A month can carry both markers at once — a version starting in a month the user also overrode is unusual but legal, and showing only one would hide the reason the number is what it is. The override still wins the allocation, per the precedence above.
Actions
setRuleFrom(draft, postId, from: MonthId, rule: Rule)— inserts or replaces the version for that month, keepingrulessorted. Rounds the rule at the mutation boundary like every other money-shaped write.removeRuleFrom(draft, postId, from: MonthId)— deletes that version. Removing the earliest one means earlier months resolve to 0.
Validation
parseDatasetJson must, for each post: accept a rules array; reject a
malformed or out-of-range from month using the existing tightened month
pattern; and reject duplicate from months, since the resulting effective rule
would be ambiguous.
Over-budget remains always permitted: no rule value is rejected for magnitude, and percentages above 100% stay legal.
Testing
ruleAt: before the first version ->null; exactly on a version's month; between two versions; after the last.allocationFor: no rule -> 0; an override beats the effective version; a version applies from its month onward.- The fold across a rule change — 10% through June, 15% from July — asserting the allocation steps in July and that the rollover carries correctly across the boundary. This is the test that proves the feature, because the fold is what every view reads.
- The migration preserves behaviour. Build a v1 dataset, migrate it, and assert every month's allocation is identical before and after. A shape-only test would pass while balances moved; this is the property that matters.
- JSON import accepts v2, migrates v1, and rejects duplicate
frommonths. setRuleFromreplaces rather than appends for a month that already has a version, and keeps the array sorted.- Archived posts still resolve rules, since they still fold.
Not covered: anything visual in either editing surface, and the real IndexedDB upgrade path. Both need a browser.
Files
| File | Change |
|---|---|
src/domain/types.ts |
RuleVersion; Post.rules replaces standingRule |
src/domain/allocation.ts |
ruleAt; precedence in allocationFor |
src/domain/seed.ts |
seed posts start with empty rules; SCHEMA_VERSION 2 |
src/store/migrations.ts |
the v1 -> v2 migration |
src/store/actions.ts |
setRuleFrom, removeRuleFrom; addPost/updatePost |
src/export/json.ts |
validate rules, reject duplicate months |
src/ui/routes/settings/PostsSection.tsx |
history editor |
src/ui/routes/MonthRoute.tsx, src/ui/components/PostTable.tsx |
change-from-here control; version-starts-here marker |