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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 14:11:42 +02:00
1 parent 1cb91d37ea
commit e3023d0322
2 files changed
+213 -48

No files matched your search

+141
View File
@@ -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<string>(currentMonth);
const [kind, setKind] = useState<Rule["kind"]>("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<string>("");
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 (
<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"
aria-label="Rule starts from month"
value={from}
onChange={(event) => {
const next = event.target.value;
setFrom(next);
}}
/>
<select
className="h-8 rounded border bg-background px-1 text-xs"
aria-label="Rule kind"
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"
aria-label={kind === "fixed" ? `Amount in ${post.currency}` : "Percent of income"}
value={value}
onChange={(event) => {
const next = event.target.value;
setValue(next);
}}
/>
<Button size="sm" variant="outline" disabled={!monthOk || !valueOk} onClick={add}>
{replaces ? "Replace" : "Add"}
</Button>
{!monthOk && from.trim() !== "" && (
<span className="text-xs text-muted-foreground">
Needs a month as YYYY-MM, 01 to 12.
</span>
)}
{monthOk && replaces && (
<span className="text-xs text-muted-foreground">
{from} already has a rule; this replaces it.
</span>
)}
</div>
</div>
);
}
+72 -48
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { Fragment, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { useDataset } from "../../hooks/useDataset.ts"; 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 { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts";
import { ruleAt } from "../../../domain/allocation.ts"; import { ruleAt } from "../../../domain/allocation.ts";
import { currentMonth } from "../../../store/index.ts"; import { currentMonth } from "../../../store/index.ts";
import { RuleHistory } from "../../components/RuleHistory.tsx";
export function PostsSection() { export function PostsSection() {
const dataset = useDataset(); const dataset = useDataset();
const { mutate } = useMutate(); const { mutate } = useMutate();
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [openHistory, setOpenHistory] = useState<string | null>(null);
const ordered = [...dataset.posts].sort((a, b) => a.order - b.order); const ordered = [...dataset.posts].sort((a, b) => a.order - b.order);
const base = dataset.settings.baseCurrency; const base = dataset.settings.baseCurrency;
@@ -35,9 +37,11 @@ export function PostsSection() {
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-lg font-medium">Posts</h2> <h2 className="text-lg font-medium">Posts</h2>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Standing rules apply to every month automatically. Any single month can A post's allocation is a dated series: each rule applies from its own
override its own allocation from the month view. Percentages may total month onward until the next one takes over, so changing it never
more than 100%. 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%.
</p> </p>
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -45,55 +49,75 @@ export function PostsSection() {
<tr> <tr>
<th className="py-2">Name</th> <th className="py-2">Name</th>
<th className="py-2">Display currency</th> <th className="py-2">Display currency</th>
<th className="py-2">Standing rule</th> <th className="py-2">Rule</th>
<th className="py-2" /> <th className="py-2" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ordered.map((post, index) => ( {ordered.map((post, index) => (
<tr key={post.id} className={`border-b last:border-0 ${post.archived ? "opacity-50" : ""}`}> <Fragment key={post.id}>
<td className="py-2"> <tr className={`border-b last:border-0 ${post.archived ? "opacity-50" : ""}`}>
<Input <td className="py-2">
className="h-8 w-48" <Input
value={post.name} className="h-8 w-48"
onChange={(event) => { value={post.name}
const name = event.target.value; onChange={(event) => {
mutate((draft) => updatePost(draft, post.id, { name })); const name = event.target.value;
}} mutate((draft) => updatePost(draft, post.id, { name }));
/> }}
</td> />
<td className="py-2"> </td>
<select <td className="py-2">
className="h-8 rounded border bg-background px-1 text-xs" <select
value={post.currency} className="h-8 rounded border bg-background px-1 text-xs"
onChange={(event) => { value={post.currency}
const currency = event.target.value as Currency; onChange={(event) => {
mutate((draft) => updatePost(draft, post.id, { currency })); const currency = event.target.value as Currency;
}} mutate((draft) => updatePost(draft, post.id, { currency }));
> }}
{CURRENCIES.map((currency) => ( >
<option key={currency} value={currency}> {CURRENCIES.map((currency) => (
{currency} <option key={currency} value={currency}>
</option> {currency}
))} </option>
</select> ))}
</td> </select>
<td className="py-2">{ruleSummary(post)}</td> </td>
<td className="py-2 text-right"> <td className="py-2">
<Button size="sm" variant="ghost" disabled={index === 0} <button
onClick={() => mutate((draft) => movePost(draft, post.id, -1))}> type="button"
up className="underline decoration-dotted"
</Button> aria-expanded={openHistory === post.id}
<Button size="sm" variant="ghost" disabled={index === ordered.length - 1} onClick={() =>
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}> setOpenHistory((id) => (id === post.id ? null : post.id))
down }
</Button> >
<Button size="sm" variant="ghost" {ruleSummary(post)}
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}> </button>
{post.archived ? "restore" : "archive"} </td>
</Button> <td className="py-2 text-right">
</td> <Button size="sm" variant="ghost" disabled={index === 0}
</tr> onClick={() => mutate((draft) => movePost(draft, post.id, -1))}>
up
</Button>
<Button size="sm" variant="ghost" disabled={index === ordered.length - 1}
onClick={() => mutate((draft) => movePost(draft, post.id, 1))}>
down
</Button>
<Button size="sm" variant="ghost"
onClick={() => mutate((draft) => setPostArchived(draft, post.id, !post.archived))}>
{post.archived ? "restore" : "archive"}
</Button>
</td>
</tr>
{openHistory === post.id && (
<tr className="border-b last:border-0">
<td colSpan={4} className="pb-3">
<RuleHistory post={post} />
</td>
</tr>
)}
</Fragment>
))} ))}
</tbody> </tbody>
</table> </table>