feat(settings): a recurring cost's splits are editable

The owner's first report: a cost's post was fixed at creation and could not be
changed. RecurringCost has carried splits and splitMode with the same shape as
Purchase since the first version, so this wires up a capability the data model
already had rather than adding one — and a bill can now span envelopes.

SplitEditor is narrowed from PurchaseDraft to the four fields it actually
reads. PurchaseDraft satisfies that structurally, so the dialog's call site
does not change at all.

The anchoring control is disabled once a cost has charges, matching the store's
refusal rather than offering an action that would fail on save.

Also fixes a carried item: the anchoring handler now expands a month-only
startDate in the same mutate call that switches to lastCharge anchoring,
matching the normalisation the recurrence-kind handler already had — an
everyNMonths cost anchored to lastCharge needs a day-granular startDate, and
the store validates that pairing on every write.
This commit is contained in:
Lucas Winther
2026-09-05 23:18:53 +02:00
parent 61e3773733
commit ccde30b8ea
4 changed files with 211 additions and 19 deletions
+5 -5
View File
@@ -5,17 +5,17 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Label } from "@/components/ui/label";
import { Segmented } from "./Segmented.tsx";
import { useMoneyFormat } from "../hooks/useMoneyFormat.ts";
import type { PurchaseDraft, SplitDraft } from "../purchaseForm.ts";
import type { SplitDraft, SplitTarget } from "../purchaseForm.ts";
import { splitBalance } from "../purchaseForm.ts";
import type { Post } from "../../domain/types.ts";
interface Props {
draft: PurchaseDraft;
interface Props<T extends SplitTarget> {
draft: T;
posts: Post[];
onChange: (next: PurchaseDraft) => void;
onChange: (next: T) => void;
}
export function SplitEditor({ draft, posts, onChange }: Props) {
export function SplitEditor<T extends SplitTarget>({ draft, posts, onChange }: Props<T>) {
const balance = splitBalance(draft, useDataset().settings.digits);
const fmt = useMoneyFormat();
const unit = draft.splitMode === "percent" ? "%" : draft.currency;
+14 -1
View File
@@ -31,6 +31,19 @@ export interface PurchaseDraft {
plan: PlanDraft | null;
}
/**
* The slice of a form draft that `SplitEditor` actually reads. `PurchaseDraft`
* satisfies it structurally, so the purchase dialog's call site is unchanged —
* and a recurring cost can now use the same editor without pretending to be a
* purchase.
*/
export interface SplitTarget {
amount: number;
currency: Currency;
splitMode: "percent" | "fixed";
splits: SplitDraft[];
}
export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
return {
date: monthId,
@@ -45,7 +58,7 @@ export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
}
/** What the split editor must show: how far the parts are from the whole. */
export function splitBalance(draft: PurchaseDraft, digits: number): number {
export function splitBalance(draft: SplitTarget, digits: number): number {
const target = draft.splitMode === "percent" ? 100 : draft.amount;
const sum = draft.splits.reduce((total, split) => total + split.value, 0);
return roundMoney(target - sum, digits);
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test";
import { isValidEndedFrom, isValidStartDate, normalizedStartDateFor } from "./RecurringSection.tsx";
import {
isValidEndedFrom,
isValidStartDate,
normalizedStartDateFor,
splitChangesOf,
splitTargetOf,
} from "./RecurringSection.tsx";
// C2: the "Starts" field must never be able to persist an empty or
// malformed value — `isValidStartDate` is the guard the onBlur handler uses
@@ -97,4 +103,64 @@ describe("normalizedStartDateFor", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09")).toBe("2026-09");
expect(normalizedStartDateFor("everyNMonths", "2026-09-17")).toBe("2026-09-17");
});
// The carried anchoring item: an everyNMonths cost switching to lastCharge
// needs the same expansion the kind handler already gets, because
// requireStartDateGranularity's needsDay turns true the moment anchoring is
// lastCharge, regardless of kind.
test("expands a month-only date when switching everyNMonths to lastCharge anchoring", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09", "lastCharge")).toBe("2026-09-01");
});
test("leaves the date untouched switching to calendar anchoring", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09", "calendar")).toBe("2026-09");
});
test("leaves an already day-granular date untouched under lastCharge anchoring", () => {
expect(normalizedStartDateFor("everyNMonths", "2026-09-17", "lastCharge")).toBe("2026-09-17");
});
});
describe("a cost's splits are editable", () => {
test("splitTargetOf exposes the fields SplitEditor needs", () => {
const cost = {
id: "r1", name: "Rent", order: 0, archived: false,
amount: { amount: 8200, currency: "DKK" },
startDate: "2026-09", recurrence: { kind: "everyNMonths", n: 1 },
anchoring: "calendar", splitMode: "percent",
splits: [{ postId: "a", value: 100, absorbsRemainder: true }],
} as const;
expect(splitTargetOf(cost)).toEqual({
amount: 8200,
currency: "DKK",
splitMode: "percent",
splits: [{ postId: "a", value: 100, absorbsRemainder: true }],
});
});
test("splitChangesOf turns an edited target back into a cost update", () => {
const next = {
amount: 8200, currency: "DKK", splitMode: "fixed" as const,
splits: [
{ postId: "a", value: 5000, absorbsRemainder: false },
{ postId: "b", value: 3200, absorbsRemainder: true },
],
};
expect(splitChangesOf(next)).toEqual({
splitMode: "fixed",
splits: [
{ postId: "a", value: 5000, absorbsRemainder: false },
{ postId: "b", value: 3200, absorbsRemainder: true },
],
});
});
test("splitChangesOf does not carry the amount back", () => {
// The amount has its own field in the table; letting the split editor
// write it too would give one value two owners.
expect(splitChangesOf({
amount: 999, currency: "DKK", splitMode: "percent",
splits: [{ postId: "a", value: 100, absorbsRemainder: true }],
})).not.toHaveProperty("amount");
});
});
+125 -12
View File
@@ -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 { NativeSelect } from "@/components/ui/native-select";
@@ -15,9 +15,11 @@ import {
import { parseMoneyInput } from "../../moneyInput.ts";
import { currentMonth } from "../../../store/index.ts";
import { Section } from "../../components/Section.tsx";
import { SplitEditor } from "../../components/SplitEditor.tsx";
import type { SplitTarget } from "../../purchaseForm.ts";
import { monthOf } from "../../../domain/months.ts";
import { toDayOrdinal } from "../../../domain/days.ts";
import type { IsoDate, Recurrence, RecurringCost } from "../../../domain/types.ts";
import type { IsoDate, Recurrence, RecurringCost, Split } from "../../../domain/types.ts";
const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
@@ -81,25 +83,78 @@ export function isValidEndedFrom(value: string): boolean {
/**
* What the "Starts" value becomes when the owner switches to a day-granular
* unit: a month-only value expands to the first of that month, in the SAME
* mutate call that changes the kind (C1) — the walk must never see the old
* pairing even for one render. `everyNMonths` and an already day-granular
* value pass through untouched.
* unit, or to `lastCharge` anchoring: a month-only value expands to the first
* of that month, in the SAME mutate call that changes the kind or anchoring
* (C1, and the carried anchoring item) — the walk must never see the old
* pairing even for one render.
*
* Mirrors `requireStartDateGranularity`'s `needsDay` in `store/actions.ts`:
* any kind other than `everyNMonths` always needs a day-granular date
* regardless of anchoring (so `anchoring` can be omitted by the kind
* handler, which never loosens that requirement), and `everyNMonths` needs
* one only once it is anchored to `lastCharge`. An already day-granular
* value always passes through untouched.
*
* Exported for `RecurringSection.test.ts`.
*/
export function normalizedStartDateFor(kind: Recurrence["kind"], startDate: IsoDate): IsoDate {
if (kind === "everyNMonths" || isDayGranular(startDate)) return startDate;
export function normalizedStartDateFor(
kind: Recurrence["kind"],
startDate: IsoDate,
anchoring?: RecurringCost["anchoring"],
): IsoDate {
const needsDay = kind !== "everyNMonths" || anchoring === "lastCharge";
if (!needsDay || isDayGranular(startDate)) return startDate;
return `${startDate}-01`;
}
/**
* `splits` is typed `readonly Split[]` rather than `RecurringCost`'s own
* mutable `Split[]` so a `const`-asserted test fixture (whose array is
* `readonly` all the way down) is assignable here — a real stored cost's
* mutable array is assignable to a readonly parameter just as freely.
*/
type SplitSource = Pick<RecurringCost, "amount" | "splitMode"> & {
splits: readonly Split[];
};
/** A cost, viewed as the split editor sees it. */
export function splitTargetOf(cost: SplitSource): SplitTarget {
return {
amount: cost.amount.amount,
currency: cost.amount.currency,
splitMode: cost.splitMode,
splits: cost.splits.map((s) => ({ ...s })),
};
}
/**
* The edited target, as an update to the cost. The amount is deliberately NOT
* carried back: it has its own column in the table, and one value with two
* owners is how a form starts fighting itself.
*/
export function splitChangesOf(next: SplitTarget): Partial<RecurringCost> {
return { splitMode: next.splitMode, splits: next.splits.map((s) => ({ ...s })) };
}
export function RecurringSection() {
const dataset = useDataset();
const { mutate } = useMutate();
const [newName, setNewName] = useState("");
const [openSplits, setOpenSplits] = useState<string | null>(null);
const ordered = [...dataset.recurring].sort((a, b) => a.order - b.order);
const base = dataset.settings.baseCurrency;
const firstPost = [...dataset.posts].sort((a, b) => a.order - b.order)[0];
const activePosts = dataset.posts
.filter((p) => !p.archived)
.sort((a, b) => a.order - b.order);
const postName = (postId: string) => dataset.posts.find((p) => p.id === postId)?.name;
function splitSummary(cost: RecurringCost) {
if (cost.splits.length === 1) {
return postName(cost.splits[0]!.postId) ?? "Choose a post…";
}
return `${cost.splits.length} posts`;
}
function amountCell(cost: RecurringCost) {
return (
@@ -217,13 +272,18 @@ export function RecurringSection() {
<th className="py-2 font-medium">Measured from</th>
<th className="py-2 font-medium">Starts</th>
<th className="py-2 font-medium">Ends</th>
<th className="py-2 font-medium">Split</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{ordered.map((cost, index) => (
{ordered.map((cost, index) => {
const charged = dataset.purchases.some(
(p) => p.source?.recurringId === cost.id,
);
return (
<Fragment key={cost.id}>
<tr
key={cost.id}
className={`border-b last:border-0 ${cost.archived ? "opacity-50" : ""}`}
>
<td className="py-2">
@@ -243,9 +303,28 @@ export function RecurringSection() {
className="h-8 w-auto text-xs md:text-xs"
aria-label={`Anchoring for ${cost.name}`}
value={cost.anchoring}
disabled={charged}
title={
charged
? "This cost has charges recorded, so its anchoring cannot change. End it and create a new one."
: undefined
}
onChange={(event) => {
const anchoring = event.target.value as RecurringCost["anchoring"];
mutate((draft) => updateRecurringCost(draft, cost.id, { anchoring }));
// Same C1-shaped normalisation the kind handler below
// does: an everyNMonths cost anchored to lastCharge
// needs a day-granular startDate, so expand a
// month-only one in the SAME mutate call that changes
// the anchoring — the store validates the pairing on
// every write and would otherwise refuse this one.
const startDate = normalizedStartDateFor(
cost.recurrence.kind,
cost.startDate,
anchoring,
);
mutate((draft) =>
updateRecurringCost(draft, cost.id, { anchoring, startDate }),
);
}}
>
<option value="calendar">the calendar</option>
@@ -307,6 +386,23 @@ export function RecurringSection() {
}}
/>
</td>
<td className="py-2">
{/* Same pattern PostsSection uses for rule history: a
size="xs" ghost button toggles an expandable row
holding the real editor, so the common case (one
post) stays a one-line summary. */}
<Button
type="button"
variant="ghost"
size="xs"
className="-mx-2 max-w-full font-normal underline decoration-dotted underline-offset-2"
aria-expanded={openSplits === cost.id}
aria-label={`Split for ${cost.name}`}
onClick={() => setOpenSplits((id) => (id === cost.id ? null : cost.id))}
>
{splitSummary(cost)}
</Button>
</td>
<td className="py-2">
<div className="flex items-center justify-end gap-1">
<Button
@@ -342,7 +438,24 @@ export function RecurringSection() {
</div>
</td>
</tr>
))}
{openSplits === cost.id && (
<tr className="border-b last:border-0">
<td colSpan={8} className="pb-3">
<SplitEditor
draft={splitTargetOf(cost)}
posts={activePosts}
onChange={(next) =>
mutate((draft) =>
updateRecurringCost(draft, cost.id, splitChangesOf(next)),
)
}
/>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>