docs: spec time-versioned allocation rules
A post's allocation rule becomes a dated series: a version takes effect in its month and continues until the next replaces it. Per-month overrides survive alongside as one-month exceptions, because collapsing the two loses the distinction between a permanent change and a temporary one. A post with no version yet allocates zero rather than applying its earliest rule backwards, which would invent budget for months never budgeted and let the rollover fold carry that fiction forward. Requires the project's first real migration (schemaVersion 1 -> 2). It is a pure shape change and behaviour-preserving by construction, and the spec makes that a tested property rather than an assumption: every month's allocation must be identical before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ae34f9519d
commit
acf973f322
@@ -0,0 +1,223 @@
|
||||
# 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.** `allocationFor` keeps 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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
- `rules` is sorted ascending by `from`.
|
||||
- **At most one version per month.** Setting a rule for a month that already has
|
||||
one replaces it; two versions sharing a `from` would make the effective rule
|
||||
ambiguous.
|
||||
- An empty `rules` array is legal and means the post has never been budgeted.
|
||||
- A `from` earlier than `settings.foldStartMonth` 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.
|
||||
|
||||
**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:
|
||||
|
||||
1. `month.ruleOverrides[postId]` — one-month exception, wins outright
|
||||
2. `ruleAt(post, monthId)` — the effective version
|
||||
3. 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, keeping `rules` sorted. 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 `from` months.
|
||||
- `setRuleFrom` replaces 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 |
|
||||
Reference in New Issue
Block a user