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.
This commit is contained in:
+172
-35
@@ -1,18 +1,26 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import {
|
||||
resolveRule,
|
||||
ruleAt,
|
||||
ruleForPost,
|
||||
isOverridden,
|
||||
allocationFor,
|
||||
incomeFor,
|
||||
} from "./allocation.ts";
|
||||
import type { Dataset, FxRate, Post } from "./types.ts";
|
||||
import { foldBalances, figuresFor } from "./fold.ts";
|
||||
import type { Dataset, FxRate, Post, Rule } from "./types.ts";
|
||||
|
||||
const rates: FxRate[] = [
|
||||
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
|
||||
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
|
||||
];
|
||||
|
||||
const pct = (percent: number): Rule => ({ kind: "percentOfIncome", percent });
|
||||
const fixed = (amount: number): Rule => ({
|
||||
kind: "fixed",
|
||||
amount: { amount, currency: "DKK" },
|
||||
});
|
||||
|
||||
function post(overrides: Partial<Post> = {}): Post {
|
||||
return {
|
||||
id: "p1",
|
||||
@@ -20,18 +28,18 @@ function post(overrides: Partial<Post> = {}): Post {
|
||||
order: 0,
|
||||
archived: false,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "fixed", amount: { amount: 400, currency: "DKK" } },
|
||||
rules: [{ from: "2026-01", rule: fixed(400) }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function dataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
return {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 },
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 },
|
||||
fxRates: rates,
|
||||
posts: [post()],
|
||||
months: [
|
||||
{ id: "2026-09", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
|
||||
{ id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} },
|
||||
],
|
||||
purchases: [],
|
||||
...overrides,
|
||||
@@ -40,9 +48,7 @@ function dataset(overrides: Partial<Dataset> = {}): Dataset {
|
||||
|
||||
describe("resolveRule", () => {
|
||||
test("a fixed rule in base currency is its amount", () => {
|
||||
expect(
|
||||
resolveRule({ kind: "fixed", amount: { amount: 400, currency: "DKK" } }, 20000, "DKK", rates),
|
||||
).toBe(400);
|
||||
expect(resolveRule(fixed(400), 20000, "DKK", rates)).toBe(400);
|
||||
});
|
||||
|
||||
test("a fixed rule in a foreign currency converts to base", () => {
|
||||
@@ -52,50 +58,113 @@ describe("resolveRule", () => {
|
||||
});
|
||||
|
||||
test("a percentage rule resolves against income", () => {
|
||||
expect(resolveRule({ kind: "percentOfIncome", percent: 25 }, 20000, "DKK", rates)).toBe(5000);
|
||||
});
|
||||
|
||||
test("a percentage rule rounds", () => {
|
||||
expect(resolveRule({ kind: "percentOfIncome", percent: 33 }, 1000.5, "DKK", rates)).toBe(330.17);
|
||||
expect(resolveRule(pct(25), 20000, "DKK", rates)).toBe(5000);
|
||||
});
|
||||
|
||||
test("a percentage above 100 is permitted", () => {
|
||||
expect(resolveRule({ kind: "percentOfIncome", percent: 150 }, 1000, "DKK", rates)).toBe(1500);
|
||||
expect(resolveRule(pct(150), 1000, "DKK", rates)).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("override precedence", () => {
|
||||
test("a month override wins over the standing rule", () => {
|
||||
describe("ruleAt", () => {
|
||||
const p = post({
|
||||
rules: [
|
||||
{ from: "2026-04", rule: pct(10) },
|
||||
{ from: "2026-07", rule: pct(15) },
|
||||
],
|
||||
});
|
||||
|
||||
test("returns null before the earliest version", () => {
|
||||
expect(ruleAt(p, "2026-03")).toBeNull();
|
||||
});
|
||||
|
||||
test("applies from the version's own month", () => {
|
||||
expect(ruleAt(p, "2026-04")?.rule).toEqual(pct(10));
|
||||
});
|
||||
|
||||
test("keeps applying between versions", () => {
|
||||
expect(ruleAt(p, "2026-06")?.rule).toEqual(pct(10));
|
||||
});
|
||||
|
||||
test("the later version takes over from its month", () => {
|
||||
expect(ruleAt(p, "2026-07")?.rule).toEqual(pct(15));
|
||||
});
|
||||
|
||||
test("the last version continues indefinitely", () => {
|
||||
expect(ruleAt(p, "2030-11")?.rule).toEqual(pct(15));
|
||||
});
|
||||
|
||||
test("a post with no versions has no rule at all", () => {
|
||||
expect(ruleAt(post({ rules: [] }), "2026-06")).toBeNull();
|
||||
});
|
||||
|
||||
test("resolves correctly even if the array is not sorted", () => {
|
||||
const unsorted = post({
|
||||
rules: [
|
||||
{ from: "2026-07", rule: pct(15) },
|
||||
{ from: "2026-04", rule: pct(10) },
|
||||
],
|
||||
});
|
||||
expect(ruleAt(unsorted, "2026-05")?.rule).toEqual(pct(10));
|
||||
expect(ruleAt(unsorted, "2026-08")?.rule).toEqual(pct(15));
|
||||
});
|
||||
});
|
||||
|
||||
describe("precedence", () => {
|
||||
test("a month override beats the effective version", () => {
|
||||
const data = dataset({
|
||||
posts: [post({ rules: [{ from: "2026-01", rule: fixed(400) }] })],
|
||||
months: [
|
||||
{
|
||||
id: "2026-09",
|
||||
id: "2026-01",
|
||||
income: { amount: 20000, currency: "DKK" },
|
||||
ruleOverrides: { p1: { kind: "fixed", amount: { amount: 999, currency: "DKK" } } },
|
||||
ruleOverrides: { p1: fixed(999) },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(allocationFor(data, "p1", "2026-09")).toBe(999);
|
||||
expect(allocationFor(data, "p1", "2026-01")).toBe(999);
|
||||
expect(isOverridden(data.months[0], "p1")).toBe(true);
|
||||
});
|
||||
|
||||
test("without an override the standing rule applies", () => {
|
||||
const data = dataset();
|
||||
expect(allocationFor(data, "p1", "2026-09")).toBe(400);
|
||||
expect(isOverridden(data.months[0], "p1")).toBe(false);
|
||||
test("without an override the effective version applies", () => {
|
||||
expect(allocationFor(dataset(), "p1", "2026-01")).toBe(400);
|
||||
});
|
||||
|
||||
test("ruleForPost falls back to the standing rule when the month is unknown", () => {
|
||||
expect(ruleForPost(undefined, post())).toEqual({
|
||||
kind: "fixed",
|
||||
amount: { amount: 400, currency: "DKK" },
|
||||
test("no applicable version means an allocation of zero", () => {
|
||||
const data = dataset({ posts: [post({ rules: [{ from: "2026-06", rule: fixed(400) }] })] });
|
||||
expect(allocationFor(data, "p1", "2026-01")).toBe(0);
|
||||
});
|
||||
|
||||
test("a post with no versions allocates zero rather than throwing", () => {
|
||||
expect(allocationFor(dataset({ posts: [post({ rules: [] })] }), "p1", "2026-01")).toBe(0);
|
||||
});
|
||||
|
||||
test("an override still applies in a month with no version", () => {
|
||||
const data = dataset({
|
||||
posts: [post({ rules: [] })],
|
||||
months: [
|
||||
{
|
||||
id: "2026-01",
|
||||
income: { amount: 20000, currency: "DKK" },
|
||||
ruleOverrides: { p1: fixed(250) },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(allocationFor(data, "p1", "2026-01")).toBe(250);
|
||||
});
|
||||
|
||||
test("ruleForPost returns null when nothing applies", () => {
|
||||
expect(ruleForPost(undefined, post({ rules: [] }), "2026-01")).toBeNull();
|
||||
});
|
||||
|
||||
test("an unknown post id throws", () => {
|
||||
expect(() => allocationFor(dataset(), "nope", "2026-01")).toThrow(/nope/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("incomeFor", () => {
|
||||
test("returns the month's income", () => {
|
||||
expect(incomeFor(dataset(), "2026-09")).toBe(20000);
|
||||
expect(incomeFor(dataset(), "2026-01")).toBe(20000);
|
||||
});
|
||||
|
||||
test("a month with no record has zero income", () => {
|
||||
@@ -103,13 +172,81 @@ describe("incomeFor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a percentage allocation in a month with no record is zero", () => {
|
||||
const data = dataset({
|
||||
posts: [post({ standingRule: { kind: "percentOfIncome", percent: 25 } })],
|
||||
});
|
||||
expect(allocationFor(data, "p1", "2026-10")).toBe(0);
|
||||
});
|
||||
describe("the fold across a rule change", () => {
|
||||
// The feature's headline behaviour: 10% through June, 15% from July, with the
|
||||
// rollover carrying correctly across the boundary.
|
||||
const data: Dataset = {
|
||||
settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2 },
|
||||
fxRates: [],
|
||||
posts: [
|
||||
post({
|
||||
rules: [
|
||||
{ from: "2026-04", rule: pct(10) },
|
||||
{ from: "2026-07", rule: pct(15) },
|
||||
],
|
||||
}),
|
||||
],
|
||||
months: ["2026-05", "2026-06", "2026-07", "2026-08"].map((id) => ({
|
||||
id,
|
||||
income: { amount: 10000, currency: "DKK" },
|
||||
ruleOverrides: {},
|
||||
})),
|
||||
purchases: [],
|
||||
};
|
||||
|
||||
test("an unknown post id throws", () => {
|
||||
expect(() => allocationFor(dataset(), "nope", "2026-09")).toThrow(/nope/);
|
||||
test("the allocation steps up in the version's month", () => {
|
||||
const fold = foldBalances(data, "2026-08");
|
||||
expect(figuresFor(fold, "p1", "2026-06").allocation).toBe(1000);
|
||||
expect(figuresFor(fold, "p1", "2026-07").allocation).toBe(1500);
|
||||
});
|
||||
|
||||
test("unspent budget carries across the change", () => {
|
||||
const fold = foldBalances(data, "2026-08");
|
||||
// 1000 + 1000 = 2000 carried into July, + 1500 = 3500, + 1500 = 5000.
|
||||
expect(figuresFor(fold, "p1", "2026-07").carriedIn).toBe(2000);
|
||||
expect(figuresFor(fold, "p1", "2026-08").remaining).toBe(5000);
|
||||
});
|
||||
|
||||
test("a version dated before the fold start is legal but inert", () => {
|
||||
// The fold never evaluates months before its start, so such a version
|
||||
// simply has no effect until the fold start moves earlier.
|
||||
const early: Dataset = {
|
||||
...data,
|
||||
posts: [
|
||||
post({
|
||||
rules: [
|
||||
{ from: "2020-01", rule: pct(99) },
|
||||
{ from: "2026-04", rule: pct(10) },
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
const fold = foldBalances(early, "2026-06");
|
||||
expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000);
|
||||
expect(fold.has("2020-01")).toBe(false);
|
||||
});
|
||||
|
||||
test("an archived post still resolves its rules, because it still folds", () => {
|
||||
const archived: Dataset = {
|
||||
...data,
|
||||
posts: [post({ archived: true, rules: [{ from: "2026-05", rule: pct(10) }] })],
|
||||
};
|
||||
const fold = foldBalances(archived, "2026-06");
|
||||
expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000);
|
||||
expect(figuresFor(fold, "p1", "2026-06").carriedIn).toBe(1000);
|
||||
});
|
||||
|
||||
test("months before the earliest version allocate nothing", () => {
|
||||
const early: Dataset = {
|
||||
...data,
|
||||
settings: { ...data.settings, foldStartMonth: "2026-02" },
|
||||
months: [
|
||||
{ id: "2026-02", income: { amount: 10000, currency: "DKK" }, ruleOverrides: {} },
|
||||
...data.months,
|
||||
],
|
||||
};
|
||||
const fold = foldBalances(early, "2026-05");
|
||||
expect(figuresFor(fold, "p1", "2026-02").allocation).toBe(0);
|
||||
expect(figuresFor(fold, "p1", "2026-05").allocation).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toBase } from "./fx.ts";
|
||||
import { roundMoney } from "./money.ts";
|
||||
import { compareMonths } from "./months.ts";
|
||||
import type {
|
||||
Currency,
|
||||
Dataset,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
Post,
|
||||
PostId,
|
||||
Rule,
|
||||
RuleVersion,
|
||||
} from "./types.ts";
|
||||
|
||||
/** Resolves a rule to a base-currency amount. Percentages may exceed 100. */
|
||||
@@ -24,8 +26,36 @@ export function resolveRule(
|
||||
return roundMoney((income * rule.percent) / 100, baseCurrency);
|
||||
}
|
||||
|
||||
export function ruleForPost(month: Month | undefined, post: Post): Rule {
|
||||
return month?.ruleOverrides[post.id] ?? post.standingRule;
|
||||
/**
|
||||
* The version in effect for `monthId`: the last one whose `from` is at or
|
||||
* before it, or null when the post was not yet budgeted.
|
||||
*
|
||||
* Scans rather than assuming sorted order. The array is kept sorted by
|
||||
* `setRuleFrom`, but a hand-edited import must still resolve correctly.
|
||||
*/
|
||||
export function ruleAt(post: Post, monthId: MonthId): RuleVersion | null {
|
||||
let effective: RuleVersion | null = null;
|
||||
for (const version of post.rules) {
|
||||
if (compareMonths(version.from, monthId) > 0) continue;
|
||||
if (effective === null || compareMonths(version.from, effective.from) > 0) {
|
||||
effective = version;
|
||||
}
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* Precedence: a one-month override wins outright; otherwise the version in
|
||||
* effect; otherwise nothing, which the caller renders as an allocation of zero.
|
||||
*/
|
||||
export function ruleForPost(
|
||||
month: Month | undefined,
|
||||
post: Post,
|
||||
monthId: MonthId,
|
||||
): Rule | null {
|
||||
const override = month?.ruleOverrides[post.id];
|
||||
if (override) return override;
|
||||
return ruleAt(post, monthId)?.rule ?? null;
|
||||
}
|
||||
|
||||
export function isOverridden(month: Month | undefined, postId: PostId): boolean {
|
||||
@@ -46,8 +76,10 @@ export function allocationFor(
|
||||
const post = dataset.posts.find((p) => p.id === postId);
|
||||
if (!post) throw new Error(`Unknown post: ${postId}`);
|
||||
const month = dataset.months.find((m) => m.id === monthId);
|
||||
const rule = ruleForPost(month, post, monthId);
|
||||
if (rule === null) return 0;
|
||||
return resolveRule(
|
||||
ruleForPost(month, post),
|
||||
rule,
|
||||
incomeFor(dataset, monthId),
|
||||
dataset.settings.baseCurrency,
|
||||
dataset.fxRates,
|
||||
|
||||
@@ -9,7 +9,7 @@ function post(id: string, fixed: number, archived = false): Post {
|
||||
order: 0,
|
||||
archived,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } },
|
||||
rules: [{ from: "2026-01", rule: { kind: "fixed", amount: { amount: fixed, currency: "DKK" } } }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ test("percentage allocations follow the month's income", () => {
|
||||
order: 0,
|
||||
archived: false,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "percentOfIncome", percent: 10 },
|
||||
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 10 } }],
|
||||
},
|
||||
],
|
||||
months: [
|
||||
|
||||
@@ -12,14 +12,11 @@ test("seeds exactly the three named posts, in order", () => {
|
||||
expect(data.posts.map((p) => p.order)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
test("seeded posts are active with a zero fixed standing rule", () => {
|
||||
test("seeded posts are active and unbudgeted", () => {
|
||||
const data = createSeedDataset("2026-09");
|
||||
for (const post of data.posts) {
|
||||
expect(post.archived).toBe(false);
|
||||
expect(post.standingRule).toEqual({
|
||||
kind: "fixed",
|
||||
amount: { amount: 0, currency: "DKK" },
|
||||
});
|
||||
expect(post.rules).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
import type { Dataset, MonthId, Post } from "./types.ts";
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
export const SCHEMA_VERSION = 2;
|
||||
|
||||
const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const;
|
||||
|
||||
@@ -19,7 +19,9 @@ export function createSeedDataset(startMonth: MonthId): Dataset {
|
||||
order,
|
||||
archived: false,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "fixed", amount: { amount: 0, currency: "DKK" } },
|
||||
// A new user has genuinely not budgeted anything yet. This is a different
|
||||
// state from a rule of zero, and the app distinguishes them.
|
||||
rules: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
+12
-1
@@ -28,6 +28,12 @@ export type Rule =
|
||||
| { kind: "fixed"; amount: Money }
|
||||
| { kind: "percentOfIncome"; percent: number };
|
||||
|
||||
export interface RuleVersion {
|
||||
/** The rule takes effect in this month and continues until the next version. */
|
||||
from: MonthId;
|
||||
rule: Rule;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: PostId;
|
||||
name: string;
|
||||
@@ -35,7 +41,12 @@ export interface Post {
|
||||
archived: boolean;
|
||||
/** Display currency for this post's own views only. All totals use base. */
|
||||
currency: Currency;
|
||||
standingRule: Rule;
|
||||
/**
|
||||
* The post's allocation rule over time, sorted ascending by `from`, at most
|
||||
* one entry per month. Empty means the post has never been budgeted, and its
|
||||
* allocation is zero — not the same as a rule of zero.
|
||||
*/
|
||||
rules: RuleVersion[];
|
||||
}
|
||||
|
||||
export interface Month {
|
||||
|
||||
@@ -9,7 +9,7 @@ function post(id: string, order: number, percent: number, archived = false): Pos
|
||||
order,
|
||||
archived,
|
||||
currency: "DKK",
|
||||
standingRule: { kind: "percentOfIncome", percent },
|
||||
rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent } }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { Dataset } from "../domain/types.ts";
|
||||
function populated(): Dataset {
|
||||
const data = createSeedDataset("2026-01");
|
||||
data.months[0]!.income = { amount: 20000, currency: "DKK" };
|
||||
data.posts[1]!.standingRule = { kind: "percentOfIncome", percent: 25 };
|
||||
data.posts[1]!.rules = [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 25 } }];
|
||||
data.purchases.push({
|
||||
id: "p1",
|
||||
date: "2026-01-15",
|
||||
|
||||
@@ -77,34 +77,16 @@ describe("months", () => {
|
||||
describe("posts", () => {
|
||||
test("addPost appends with the next order value", () => {
|
||||
const data = draft();
|
||||
const post = actions.addPost(data, "Travel", "EUR", {
|
||||
kind: "percentOfIncome",
|
||||
percent: 5,
|
||||
});
|
||||
const post = actions.addPost(data, "Travel", "EUR");
|
||||
expect(post.order).toBe(3);
|
||||
expect(post.archived).toBe(false);
|
||||
expect(data.posts).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("addPost rounds a fixed standing rule's amount and a percent's percent", () => {
|
||||
test("addPost starts the post unbudgeted", () => {
|
||||
const data = draft();
|
||||
const fixedPost = actions.addPost(data, "Rent", "DKK", {
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.345, currency: "DKK" },
|
||||
});
|
||||
expect(fixedPost.standingRule).toEqual({
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.35, currency: "DKK" },
|
||||
});
|
||||
|
||||
const percentPost = actions.addPost(data, "Travel", "EUR", {
|
||||
kind: "percentOfIncome",
|
||||
percent: 12.3456,
|
||||
});
|
||||
expect(percentPost.standingRule).toEqual({
|
||||
kind: "percentOfIncome",
|
||||
percent: 12.35,
|
||||
});
|
||||
const post = actions.addPost(data, "Travel", "EUR");
|
||||
expect(post.rules).toEqual([]);
|
||||
});
|
||||
|
||||
test("setPostArchived toggles without deleting", () => {
|
||||
@@ -140,26 +122,6 @@ describe("posts", () => {
|
||||
expect(post.currency).toBe("DKK");
|
||||
});
|
||||
|
||||
test("updatePost rounds a fixed standing rule's amount and a percent's percent", () => {
|
||||
const data = draft();
|
||||
const postId = data.posts[0]!.id;
|
||||
actions.updatePost(data, postId, {
|
||||
standingRule: { kind: "fixed", amount: { amount: 12.345, currency: "DKK" } },
|
||||
});
|
||||
expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({
|
||||
kind: "fixed",
|
||||
amount: { amount: 12.35, currency: "DKK" },
|
||||
});
|
||||
|
||||
actions.updatePost(data, postId, {
|
||||
standingRule: { kind: "percentOfIncome", percent: 12.3456 },
|
||||
});
|
||||
expect(data.posts.find((p) => p.id === postId)!.standingRule).toEqual({
|
||||
kind: "percentOfIncome",
|
||||
percent: 12.35,
|
||||
});
|
||||
});
|
||||
|
||||
test("an unknown post id throws", () => {
|
||||
expect(() => actions.updatePost(draft(), "ghost", { name: "x" })).toThrow(/ghost/);
|
||||
});
|
||||
|
||||
+2
-10
@@ -102,19 +102,14 @@ export function setRuleOverride(
|
||||
month.ruleOverrides[postId] = roundRule(rule);
|
||||
}
|
||||
|
||||
export function addPost(
|
||||
draft: Dataset,
|
||||
name: string,
|
||||
currency: Currency,
|
||||
standingRule: Rule,
|
||||
): Post {
|
||||
export function addPost(draft: Dataset, name: string, currency: Currency): Post {
|
||||
const post: Post = {
|
||||
id: newId(),
|
||||
name,
|
||||
order: draft.posts.length,
|
||||
archived: false,
|
||||
currency,
|
||||
standingRule: roundRule(standingRule),
|
||||
rules: [],
|
||||
};
|
||||
draft.posts.push(post);
|
||||
return post;
|
||||
@@ -127,9 +122,6 @@ export function updatePost(
|
||||
): void {
|
||||
const post = requirePost(draft, postId);
|
||||
const resolved: Partial<Omit<Post, "id">> = { ...changes };
|
||||
if (changes.standingRule) {
|
||||
resolved.standingRule = roundRule(changes.standingRule);
|
||||
}
|
||||
Object.assign(post, resolved);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { useDataset } from "../../hooks/useDataset.ts";
|
||||
import { useMutate } from "../../hooks/useMutate.ts";
|
||||
import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts";
|
||||
import { CURRENCIES, type Currency, type Rule } from "../../../domain/types.ts";
|
||||
import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts";
|
||||
import { ruleAt } from "../../../domain/allocation.ts";
|
||||
import { currentMonth } from "../../../store/index.ts";
|
||||
|
||||
export function PostsSection() {
|
||||
const dataset = useDataset();
|
||||
@@ -13,48 +15,19 @@ export function PostsSection() {
|
||||
const ordered = [...dataset.posts].sort((a, b) => a.order - b.order);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
|
||||
function ruleEditor(postId: string, rule: Rule) {
|
||||
function ruleSummary(post: Post) {
|
||||
const effective = ruleAt(post, currentMonth);
|
||||
if (!effective) {
|
||||
return <span className="text-xs text-muted-foreground">not budgeted</span>;
|
||||
}
|
||||
const { rule } = effective;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="h-8 rounded border bg-background px-1 text-xs"
|
||||
value={rule.kind}
|
||||
onChange={(event) => {
|
||||
const kind = event.target.value;
|
||||
mutate((draft) =>
|
||||
updatePost(draft, postId, {
|
||||
standingRule:
|
||||
kind === "fixed"
|
||||
? { kind: "fixed", amount: { amount: 0, currency: base } }
|
||||
: { kind: "percentOfIncome", percent: 0 },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="fixed">fixed amount</option>
|
||||
<option value="percentOfIncome">% of income</option>
|
||||
</select>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
className="font-money h-8 w-28"
|
||||
value={rule.kind === "fixed" ? rule.amount.amount : rule.percent}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value) || 0;
|
||||
mutate((draft) =>
|
||||
updatePost(draft, postId, {
|
||||
standingRule:
|
||||
rule.kind === "fixed"
|
||||
? { kind: "fixed", amount: { ...rule.amount, amount: value } }
|
||||
: { kind: "percentOfIncome", percent: value },
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="w-10 text-xs text-muted-foreground">
|
||||
{rule.kind === "fixed" ? rule.amount.currency : "%"}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,7 +78,7 @@ export function PostsSection() {
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2">{ruleEditor(post.id, post.standingRule)}</td>
|
||||
<td className="py-2">{ruleSummary(post)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="ghost" disabled={index === 0}
|
||||
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
|
||||
@@ -140,12 +113,7 @@ export function PostsSection() {
|
||||
variant="outline"
|
||||
disabled={newName.trim() === ""}
|
||||
onClick={() => {
|
||||
mutate((draft) =>
|
||||
addPost(draft, newName.trim(), base, {
|
||||
kind: "fixed",
|
||||
amount: { amount: 0, currency: base },
|
||||
}),
|
||||
);
|
||||
mutate((draft) => addPost(draft, newName.trim(), base));
|
||||
setNewName("");
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user