diff --git a/src/ui/components/SplitEditor.tsx b/src/ui/components/SplitEditor.tsx index 924cd28..f21fd5d 100644 --- a/src/ui/components/SplitEditor.tsx +++ b/src/ui/components/SplitEditor.tsx @@ -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 { + draft: T; posts: Post[]; - onChange: (next: PurchaseDraft) => void; + onChange: (next: T) => void; } -export function SplitEditor({ draft, posts, onChange }: Props) { +export function SplitEditor({ draft, posts, onChange }: Props) { const balance = splitBalance(draft, useDataset().settings.digits); const fmt = useMoneyFormat(); const unit = draft.splitMode === "percent" ? "%" : draft.currency; diff --git a/src/ui/purchaseForm.ts b/src/ui/purchaseForm.ts index b76025e..cdc3323 100644 --- a/src/ui/purchaseForm.ts +++ b/src/ui/purchaseForm.ts @@ -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); diff --git a/src/ui/routes/settings/RecurringSection.test.ts b/src/ui/routes/settings/RecurringSection.test.ts index c276506..1f2e0d7 100644 --- a/src/ui/routes/settings/RecurringSection.test.ts +++ b/src/ui/routes/settings/RecurringSection.test.ts @@ -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"); + }); }); diff --git a/src/ui/routes/settings/RecurringSection.tsx b/src/ui/routes/settings/RecurringSection.tsx index 1b2fb0d..e354b7d 100644 --- a/src/ui/routes/settings/RecurringSection.tsx +++ b/src/ui/routes/settings/RecurringSection.tsx @@ -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 & { + 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 { + 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(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() { Measured from Starts Ends + Split - {ordered.map((cost, index) => ( + {ordered.map((cost, index) => { + const charged = dataset.purchases.some( + (p) => p.source?.recurringId === cost.id, + ); + return ( + @@ -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 }), + ); }} > @@ -307,6 +386,23 @@ export function RecurringSection() { }} /> + + {/* 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. */} + +