From f94e45871d2896d4a46961a905b8ea01ef43d5fd Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 14:13:29 +0200 Subject: [PATCH] feat(ui): change a post's rule from the month you are looking at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ui/components/PostTable.tsx | 29 ++++++++- src/ui/routes/MonthRoute.tsx | 106 +++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 871d5a0..f541b08 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -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 (
@@ -87,11 +90,33 @@ export function PostTable({ monthId, baseCurrency, rows }: Props) { overridden )} + {/* 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 && ( + + rule changes here + + )} + {onChangeRule && ( + + )}
(null); + const changingPost = changingRuleFor + ? (dataset.posts.find((p) => p.id === changingRuleFor) ?? null) + : null; return (
@@ -82,7 +89,25 @@ export function MonthRoute() { - + + + {changingPost && ( + setChangingRuleFor(null)} + /> + )}
@@ -132,3 +157,80 @@ export function MonthRoute() {
); } + +/** + * "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(initial?.kind ?? "percentOfIncome"); + const [value, setValue] = useState( + 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 ( +
+ + {post.name}: allocate from {monthId} onward + + + { + const next = event.target.value; + setValue(next); + }} + /> + + +
+ ); +}