feat(ui): change a post's rule from the month you are looking at

The natural moment to decide "15% from July" is while looking at July. The
control writes the same setRuleFrom action the history editor uses, so the two
surfaces cannot drift apart. A month where a version begins gets its own badge,
distinct from a one-month override, so a step in the numbers has a visible
cause; a month can carry both.

The editor is keyed on post and month so switching posts remounts it — without
that, React reuses the instance and useState keeps the previous post's values.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 14:13:29 +02:00
co-authored by Claude Opus 5
parent e3023d0322
commit f94e45871d
2 changed files with 131 additions and 4 deletions
+27 -2
View File
@@ -2,6 +2,7 @@ import { Fragment } from "react";
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { ruleAt } from "../../domain/allocation.ts";
import type { MonthPostRow } from "../../domain/views.ts";
import type { PostMonthFigures } from "../../domain/fold.ts";
import type { Currency, MonthId } from "../../domain/types.ts";
@@ -10,6 +11,8 @@ interface Props {
monthId: MonthId;
baseCurrency: Currency;
rows: MonthPostRow[];
/** Omitted on read-only mounts; only the month view offers rule editing. */
onChangeRule?: (postId: string) => void;
}
/**
@@ -57,7 +60,7 @@ function CarryMeter({ figures }: { figures: PostMonthFigures }) {
);
}
export function PostTable({ monthId, baseCurrency, rows }: Props) {
export function PostTable({ monthId, baseCurrency, rows, onChangeRule }: Props) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -87,11 +90,33 @@ export function PostTable({ monthId, baseCurrency, rows }: Props) {
<Badge
variant="secondary"
className="ml-2"
title="This month overrides the standing rule"
title="This month overrides the rule otherwise in effect"
>
overridden
</Badge>
)}
{/* A month can carry BOTH badges: a version starting in a
month the user also overrode is unusual but legal, and
hiding either would obscure why the number is what it
is. The override still wins the allocation. */}
{ruleAt(post, monthId)?.from === monthId && (
<Badge
variant="outline"
className="ml-2"
title="This post's allocation rule changes from this month"
>
rule changes here
</Badge>
)}
{onChangeRule && (
<button
type="button"
className="ml-2 text-xs text-muted-foreground underline decoration-dotted"
onClick={() => onChangeRule(post.id)}
>
change from here
</button>
)}
</td>
<td
className={`font-money py-2 text-right ${
+104 -2
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Link, useParams } from "react-router";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -6,7 +7,9 @@ import { useDataset } from "../hooks/useDataset.ts";
import { useMutate } from "../hooks/useMutate.ts";
import { monthView } from "../../domain/views.ts";
import { addMonths } from "../../domain/months.ts";
import { setIncome, deletePurchase } from "../../store/actions.ts";
import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts";
import { ruleAt } from "../../domain/allocation.ts";
import type { MonthId, Post, Rule } from "../../domain/types.ts";
import { sliceAmountForMonth } from "../../domain/charges.ts";
import { formatMoney, formatSignedMoney } from "../format.ts";
import { PostTable } from "../components/PostTable.tsx";
@@ -18,6 +21,10 @@ export function MonthRoute() {
const { mutate, error, clearError } = useMutate();
const view = monthView(dataset, monthId);
const base = dataset.settings.baseCurrency;
const [changingRuleFor, setChangingRuleFor] = useState<string | null>(null);
const changingPost = changingRuleFor
? (dataset.posts.find((p) => p.id === changingRuleFor) ?? null)
: null;
return (
<section className="space-y-6">
@@ -82,7 +89,25 @@ export function MonthRoute() {
</dl>
</div>
<PostTable monthId={monthId} baseCurrency={base} rows={view.rows} />
<PostTable
monthId={monthId}
baseCurrency={base}
rows={view.rows}
onChangeRule={setChangingRuleFor}
/>
{changingPost && (
<RuleFromMonth
// Keyed so switching posts (or months) remounts the editor. Without
// it React reuses the instance and useState keeps the PREVIOUS
// post's kind and value, silently pre-filling the wrong rule.
key={`${changingPost.id}:${monthId}`}
post={changingPost}
monthId={monthId}
initial={ruleAt(changingPost, monthId)?.rule}
onDone={() => setChangingRuleFor(null)}
/>
)}
<section className="space-y-3">
<div className="flex items-center justify-between">
@@ -132,3 +157,80 @@ export function MonthRoute() {
</section>
);
}
/**
* "15% from July" is a decision made while looking at July. This writes the
* same `setRuleFrom` action the Settings history editor uses, so the two
* surfaces cannot drift apart.
*/
function RuleFromMonth({
post,
monthId,
initial,
onDone,
}: {
post: Post;
monthId: MonthId;
initial: Rule | undefined;
onDone: () => void;
}) {
const { mutate } = useMutate();
// Pre-filled with what is currently in effect, so nudging 10% to 15% is an
// edit rather than re-entry. Empty when the post has no rule yet. Held as
// text so the field can be cleared mid-edit; parsed once, on apply.
const [kind, setKind] = useState<Rule["kind"]>(initial?.kind ?? "percentOfIncome");
const [value, setValue] = useState<string>(
initial === undefined
? ""
: String(initial.kind === "fixed" ? initial.amount.amount : initial.percent),
);
const amount = Number(value);
const valueOk = value.trim() !== "" && Number.isFinite(amount);
function apply() {
const rule: Rule =
kind === "fixed"
? { kind: "fixed", amount: { amount, currency: post.currency } }
: { kind: "percentOfIncome", percent: amount };
mutate((draft) => setRuleFrom(draft, post.id, monthId, rule));
onDone();
}
return (
<div className="flex flex-wrap items-end gap-2 rounded border p-3 text-sm">
<span>
{post.name}: allocate from <span className="font-money">{monthId}</span> onward
</span>
<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" disabled={!valueOk} onClick={apply}>
Apply
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
Cancel
</Button>
</div>
);
}