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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1cb91d37ea
commit
e3023d0322
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const ordered = [...dataset.posts].sort((a, b) => a.order - b.order);
|
||||
const base = dataset.settings.baseCurrency;
|
||||
|
||||
@@ -35,9 +37,11 @@ export function PostsSection() {
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-medium">Posts</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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%.
|
||||
</p>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
@@ -45,55 +49,75 @@ export function PostsSection() {
|
||||
<tr>
|
||||
<th className="py-2">Name</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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ordered.map((post, index) => (
|
||||
<tr key={post.id} className={`border-b last:border-0 ${post.archived ? "opacity-50" : ""}`}>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
className="h-8 w-48"
|
||||
value={post.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
mutate((draft) => updatePost(draft, post.id, { name }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<select
|
||||
className="h-8 rounded border bg-background px-1 text-xs"
|
||||
value={post.currency}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value as Currency;
|
||||
mutate((draft) => updatePost(draft, post.id, { currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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))}>
|
||||
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>
|
||||
<Fragment key={post.id}>
|
||||
<tr className={`border-b last:border-0 ${post.archived ? "opacity-50" : ""}`}>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
className="h-8 w-48"
|
||||
value={post.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
mutate((draft) => updatePost(draft, post.id, { name }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<select
|
||||
className="h-8 rounded border bg-background px-1 text-xs"
|
||||
value={post.currency}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value as Currency;
|
||||
mutate((draft) => updatePost(draft, post.id, { currency }));
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency} value={currency}>
|
||||
{currency}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<button
|
||||
type="button"
|
||||
className="underline decoration-dotted"
|
||||
aria-expanded={openHistory === post.id}
|
||||
onClick={() =>
|
||||
setOpenHistory((id) => (id === post.id ? null : post.id))
|
||||
}
|
||||
>
|
||||
{ruleSummary(post)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<Button size="sm" variant="ghost" disabled={index === 0}
|
||||
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>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user