From e3023d03226a9d232f4d6efabc5a4605512372ad Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:11:42 +0200 Subject: [PATCH] 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. The Add button stays disabled until the month parses, because setRuleFrom sorts with compareMonths, which throws on a malformed MonthId. Also retires the "Standing rule" wording, which Task 1's model change left stale. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/components/RuleHistory.tsx | 141 ++++++++++++++++++++++++ src/ui/routes/settings/PostsSection.tsx | 120 ++++++++++++-------- 2 files changed, 213 insertions(+), 48 deletions(-) create mode 100644 src/ui/components/RuleHistory.tsx diff --git a/src/ui/components/RuleHistory.tsx b/src/ui/components/RuleHistory.tsx new file mode 100644 index 0000000..2ed3116 --- /dev/null +++ b/src/ui/components/RuleHistory.tsx @@ -0,0 +1,141 @@ +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; +} + +/** + * Shape AND range, matching the import validator. `setRuleFrom` sorts the + * series with `compareMonths`, which THROWS on a malformed MonthId; useMutate + * would catch it and show an error, but a disabled button is the better + * answer, and CLAUDE.md requires guarding a throwing domain call at the site + * that invokes it from a handler. + */ +const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; + +/** + * 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(currentMonth); + const [kind, setKind] = useState("percentOfIncome"); + // Held as text, not a number, so the field can be cleared and can hold a + // half-typed "12." mid-entry. Parsed once, on submit. + const [value, setValue] = useState(""); + + const replaces = post.rules.some((v) => v.from === from); + const monthOk = MONTH_ID.test(from); + const amount = Number(value); + const valueOk = value.trim() !== "" && Number.isFinite(amount); + + function add() { + const rule: Rule = + kind === "fixed" + ? { kind: "fixed", amount: { amount, currency: post.currency } } + : { kind: "percentOfIncome", percent: amount }; + mutate((draft) => setRuleFrom(draft, post.id, from, rule)); + } + + return ( +
+ {post.rules.length === 0 ? ( +

+ Not budgeted. Until a rule starts, this post is allocated nothing — + spending still records, and shows as overspend. +

+ ) : ( + + + + + + + + + {post.rules.map((version) => ( + + + + + + ))} + +
FromRule +
{version.from} + {version.rule.kind === "fixed" + ? `${version.rule.amount.amount} ${version.rule.amount.currency}` + : `${version.rule.percent}% of income`} + + +
+ )} + +
+ { + const next = event.target.value; + setFrom(next); + }} + /> + + { + const next = event.target.value; + setValue(next); + }} + /> + + {!monthOk && from.trim() !== "" && ( + + Needs a month as YYYY-MM, 01 to 12. + + )} + {monthOk && replaces && ( + + {from} already has a rule; this replaces it. + + )} +
+
+ ); +} diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx index 16dd6cf..32ebdf5 100644 --- a/src/ui/routes/settings/PostsSection.tsx +++ b/src/ui/routes/settings/PostsSection.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { Fragment, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useDataset } from "../../hooks/useDataset.ts"; @@ -7,11 +7,13 @@ import { addPost, movePost, setPostArchived, updatePost } from "../../../store/a import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts"; import { ruleAt } from "../../../domain/allocation.ts"; import { currentMonth } from "../../../store/index.ts"; +import { RuleHistory } from "../../components/RuleHistory.tsx"; export function PostsSection() { const dataset = useDataset(); const { mutate } = useMutate(); const [newName, setNewName] = useState(""); + const [openHistory, setOpenHistory] = useState(null); const ordered = [...dataset.posts].sort((a, b) => a.order - b.order); const base = dataset.settings.baseCurrency; @@ -35,9 +37,11 @@ export function PostsSection() {

Posts

- Standing rules apply to every month automatically. Any single month can - override its own allocation from the month view. Percentages may total - more than 100%. + A post's allocation is a dated series: each rule applies from its own + month onward until the next one takes over, so changing it never + rewrites what earlier months got. Click a rule to see and edit that + history. Any single month can still override its own allocation from + the month view. Percentages may total more than 100%.

@@ -45,55 +49,75 @@ export function PostsSection() { - + {ordered.map((post, index) => ( - - - - - - + + + + + + + + {openHistory === post.id && ( + + + + )} + ))}
Name Display currencyStanding ruleRule
- { - const name = event.target.value; - mutate((draft) => updatePost(draft, post.id, { name })); - }} - /> - - - {ruleSummary(post)} - - - -
+ { + const name = event.target.value; + mutate((draft) => updatePost(draft, post.id, { name })); + }} + /> + + + + + + + + +
+ +