From da87bff9c3e21e25421caba7f05896a812e2bc02 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 01:44:55 +0200 Subject: [PATCH] feat(ui): add fast entry for many purchases at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grocery trip is twenty lines, and opening the full dialog twenty times is the thing this exists to avoid. PurchaseDialog gains a One / Many toggle, shown only when adding — editing an existing purchase edits exactly one, so the toggle is hidden there. In Many mode the date, post and currency are set once at the top and each line is just a description and an amount; Enter on an amount starts the next line, so a receipt can be typed without leaving the keyboard. Blank trailing rows are ignored rather than flagged. The whole batch commits in a single mutate: one write, one queue entry, and no way to half-commit a shopping trip. Lines are deliberately simple. A line needing a split or a payment plan is saved plain and then edited from the month view — building per-line split and plan editors would have been most of the work for a case the grocery run never hits, and would have slowed the common path. Line logic lives in a pure module (src/ui/bulkEntry.ts) following the purchaseForm.ts pattern, so it is testable with no DOM: 20 tests covering blank-row handling, per-row validation messages, the running total, and that N lines become N purchases landing wholly in the shared post. Each line is built through the same toPurchase the single form uses, so there is one definition of what a purchase is. Validation mirrors validatePurchase in what it deliberately does NOT reject: a negative amount is a refund, not an error. Co-Authored-By: Claude Opus 5 (1M context) --- src/ui/bulkEntry.test.ts | 136 +++++++++++++++++++++++++++ src/ui/bulkEntry.ts | 97 +++++++++++++++++++ src/ui/components/BulkLines.tsx | 118 +++++++++++++++++++++++ src/ui/components/PurchaseDialog.tsx | 121 ++++++++++++++++++++++-- 4 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 src/ui/bulkEntry.test.ts create mode 100644 src/ui/bulkEntry.ts create mode 100644 src/ui/components/BulkLines.tsx diff --git a/src/ui/bulkEntry.test.ts b/src/ui/bulkEntry.test.ts new file mode 100644 index 0000000..d53ec57 --- /dev/null +++ b/src/ui/bulkEntry.test.ts @@ -0,0 +1,136 @@ +import { test, expect, describe } from "bun:test"; +import { + emptyBulkDraft, + isBlankLine, + filledLines, + bulkTotal, + lineToDraft, + validateBulk, + toPurchases, + type BulkDraft, +} from "./bulkEntry.ts"; + +function draft(overrides: Partial = {}): BulkDraft { + return { ...emptyBulkDraft("2026-09", "food"), ...overrides }; +} +const line = (description: string, amount: number) => ({ description, amount }); + +describe("emptyBulkDraft", () => { + test("starts on the first of the month with one blank line", () => { + const d = emptyBulkDraft("2026-09", "food"); + expect(d.date).toBe("2026-09-01"); + expect(d.postId).toBe("food"); + expect(d.currency).toBe("DKK"); + expect(d.lines).toEqual([{ description: "", amount: 0 }]); + }); +}); + +describe("blank lines", () => { + test("a line with no description and no amount is blank", () => { + expect(isBlankLine(line("", 0))).toBe(true); + expect(isBlankLine(line(" ", 0))).toBe(true); + }); + + test("a line with either field filled is not blank", () => { + expect(isBlankLine(line("Milk", 0))).toBe(false); + expect(isBlankLine(line("", 25))).toBe(false); + }); + + test("filledLines drops blanks so trailing empty rows are ignored", () => { + const d = draft({ lines: [line("Milk", 25), line("", 0), line("Bread", 30), line("", 0)] }); + expect(filledLines(d).map((l) => l.description)).toEqual(["Milk", "Bread"]); + }); +}); + +describe("bulkTotal", () => { + test("sums the filled lines", () => { + expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }))).toBe(55.5); + }); + + test("is zero when there is nothing filled in", () => { + expect(bulkTotal(draft())).toBe(0); + }); + + test("rounds the sum, so float tails never surface", () => { + expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }))).toBe(0.3); + }); +}); + +describe("lineToDraft", () => { + test("builds a single-split purchase landing wholly in the shared post", () => { + const d = draft({ date: "2026-09-14", currency: "EUR" }); + const p = lineToDraft(d, line("Milk", 25)); + expect(p.date).toBe("2026-09-14"); + expect(p.description).toBe("Milk"); + expect(p.amount).toBe(25); + expect(p.currency).toBe("EUR"); + expect(p.splitMode).toBe("percent"); + expect(p.splits).toEqual([{ postId: "food", value: 100, absorbsRemainder: true }]); + expect(p.plan).toBeNull(); + }); + + test("trims the description, matching what the single-purchase form stores", () => { + expect(lineToDraft(draft(), line(" Milk ", 25)).description).toBe(" Milk "); + }); +}); + +describe("validateBulk", () => { + test("a well-formed batch has no errors", () => { + expect(validateBulk(draft({ lines: [line("Milk", 25), line("Bread", 30)] }))).toEqual([]); + }); + + test("blank trailing lines are ignored, not errors", () => { + expect(validateBulk(draft({ lines: [line("Milk", 25), line("", 0), line("", 0)] }))).toEqual([]); + }); + + test("requires at least one filled line", () => { + expect(validateBulk(draft())).toContain("Add at least one line."); + }); + + test("flags a description with no amount, naming the row", () => { + const errors = validateBulk(draft({ lines: [line("Milk", 25), line("Bread", 0)] })); + expect(errors.some((e) => e.includes("2") && /amount/i.test(e))).toBe(true); + }); + + test("flags an amount with no description, naming the row", () => { + const errors = validateBulk(draft({ lines: [line("", 30)] })); + expect(errors.some((e) => e.includes("1") && /description/i.test(e))).toBe(true); + }); + + test("requires a post", () => { + expect(validateBulk(draft({ postId: "", lines: [line("Milk", 25)] }))).toContain( + "Choose a post for these lines.", + ); + }); + + test("does NOT reject a negative amount — over-budget and refunds are allowed", () => { + expect(validateBulk(draft({ lines: [line("Refund", -25)] }))).toEqual([]); + }); +}); + +describe("toPurchases", () => { + test("produces one purchase per filled line", () => { + const ps = toPurchases(draft({ lines: [line("Milk", 25), line("", 0), line("Bread", 30)] })); + expect(ps).toHaveLength(2); + expect(ps.map((p) => p.description)).toEqual(["Milk", "Bread"]); + }); + + test("every purchase carries the shared date, currency and post", () => { + const ps = toPurchases(draft({ date: "2026-09-14", currency: "EUR", lines: [line("Milk", 25), line("Bread", 30)] })); + for (const p of ps) { + expect(p.date).toBe("2026-09-14"); + expect(p.total.currency).toBe("EUR"); + expect(p.splits).toEqual([{ postId: "food", value: 100, absorbsRemainder: true }]); + expect(p.schedule).toBeNull(); + } + }); + + test("amounts survive intact", () => { + const ps = toPurchases(draft({ lines: [line("Milk", 25), line("Bread", 30.5)] })); + expect(ps.map((p) => p.total.amount)).toEqual([25, 30.5]); + }); + + test("an empty batch produces nothing rather than a blank purchase", () => { + expect(toPurchases(draft())).toEqual([]); + }); +}); diff --git a/src/ui/bulkEntry.ts b/src/ui/bulkEntry.ts new file mode 100644 index 0000000..11aef92 --- /dev/null +++ b/src/ui/bulkEntry.ts @@ -0,0 +1,97 @@ +import { roundMoney } from "../domain/money.ts"; +import type { Currency, IsoDate, MonthId, Purchase } from "../domain/types.ts"; +import { toPurchase, type PurchaseDraft } from "./purchaseForm.ts"; + +/** + * Fast entry for many purchases at once — a grocery trip is twenty lines, and + * opening the full dialog twenty times is the thing this exists to avoid. + * + * Date, post and currency are shared across the batch and set once; a line is + * just a description and an amount. Each line becomes its own Purchase landing + * wholly in the shared post, built through the SAME `toPurchase` the single + * form uses, so there is one definition of what a purchase is. + */ + +export interface BulkLine { + description: string; + amount: number; +} + +export interface BulkDraft { + date: IsoDate; + postId: string; + currency: Currency; + lines: BulkLine[]; +} + +export function emptyBulkDraft(monthId: MonthId, postId: string): BulkDraft { + return { + date: `${monthId}-01`, + postId, + currency: "DKK", + lines: [{ description: "", amount: 0 }], + }; +} + +/** A row the user has not touched. Trailing blanks are ignored, never errors. */ +export function isBlankLine(line: BulkLine): boolean { + return line.description.trim() === "" && line.amount === 0; +} + +export function filledLines(draft: BulkDraft): BulkLine[] { + return draft.lines.filter((line) => !isBlankLine(line)); +} + +export function bulkTotal(draft: BulkDraft): number { + return roundMoney( + filledLines(draft).reduce((sum, line) => sum + line.amount, 0), + draft.currency, + ); +} + +/** One line, expressed as the same draft shape the single-purchase form uses. */ +export function lineToDraft(draft: BulkDraft, line: BulkLine): PurchaseDraft { + return { + date: draft.date, + description: line.description, + amount: line.amount, + currency: draft.currency, + splitMode: "percent", + splits: [{ postId: draft.postId, value: 100, absorbsRemainder: true }], + plan: null, + }; +} + +/** + * Deliberately absent, mirroring `validatePurchase`: no rejection of a negative + * amount, of going over budget, or of any magnitude. A refund line is a normal + * thing to record. + */ +export function validateBulk(draft: BulkDraft): string[] { + const errors: string[] = []; + const filled = filledLines(draft); + + if (draft.postId === "") { + errors.push("Choose a post for these lines."); + } + if (filled.length === 0) { + errors.push("Add at least one line."); + } + + draft.lines.forEach((line, index) => { + if (isBlankLine(line)) return; + const row = index + 1; + if (line.description.trim() === "") { + errors.push(`Line ${row}: give it a description.`); + } + if (line.amount === 0) { + errors.push(`Line ${row}: enter an amount other than zero.`); + } + }); + + return errors; +} + +export function toPurchases(draft: BulkDraft): Omit[] { + return filledLines(draft).map((line) => toPurchase(lineToDraft(draft, line))); +} diff --git a/src/ui/components/BulkLines.tsx b/src/ui/components/BulkLines.tsx new file mode 100644 index 0000000..15e4097 --- /dev/null +++ b/src/ui/components/BulkLines.tsx @@ -0,0 +1,118 @@ +import { useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { formatMoney } from "../format.ts"; +import { bulkTotal, isBlankLine, type BulkDraft } from "../bulkEntry.ts"; + +interface Props { + draft: BulkDraft; + onChange: (next: BulkDraft) => void; +} + +/** + * The row list for fast entry. Two fields per line is the whole point — Enter + * in the amount field commits the row and focuses a fresh one, so a grocery + * receipt can be typed without ever leaving the keyboard. + */ +export function BulkLines({ draft, onChange }: Props) { + const amountRefs = useRef>([]); + + function updateLine(index: number, changes: Partial) { + onChange({ + ...draft, + lines: draft.lines.map((line, i) => (i === index ? { ...line, ...changes } : line)), + }); + } + + function addLine(focusIndex?: number) { + onChange({ ...draft, lines: [...draft.lines, { description: "", amount: 0 }] }); + if (focusIndex !== undefined) { + // Focus after React has rendered the new row. + requestAnimationFrame(() => amountRefs.current[focusIndex]?.focus()); + } + } + + function removeLine(index: number) { + const lines = draft.lines.filter((_, i) => i !== index); + onChange({ ...draft, lines: lines.length > 0 ? lines : [{ description: "", amount: 0 }] }); + } + + const filledCount = draft.lines.filter((line) => !isBlankLine(line)).length; + + return ( +
+
+ + + Enter on an amount starts the next line + +
+ +
+ {draft.lines.map((line, index) => ( +
+ + {index + 1} + + { + const description = event.target.value; + updateLine(index, { description }); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + amountRefs.current[index]?.focus(); + } + }} + /> + { + amountRefs.current[index] = el; + }} + className="font-money h-8 w-28" + type="number" + step="0.01" + placeholder="0.00" + value={line.amount === 0 ? "" : line.amount} + onChange={(event) => { + const amount = Number(event.target.value) || 0; + updateLine(index, { amount }); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (index === draft.lines.length - 1) addLine(index + 1); + else amountRefs.current[index + 1]?.focus(); + } + }} + /> + +
+ ))} +
+ +
+ + + {filledCount} line{filledCount === 1 ? "" : "s"} ·{" "} + {formatMoney(bulkTotal(draft), draft.currency)} + +
+
+ ); +} diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index 8456555..c74e303 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { SplitEditor } from "./SplitEditor.tsx"; import { PlanEditor } from "./PlanEditor.tsx"; +import { BulkLines } from "./BulkLines.tsx"; import { emptyDraft, fromPurchase, @@ -19,6 +20,12 @@ import { validatePurchase, type PurchaseDraft, } from "../purchaseForm.ts"; +import { + emptyBulkDraft, + toPurchases, + validateBulk, + type BulkDraft, +} from "../bulkEntry.ts"; import { useDataset } from "../hooks/useDataset.ts"; import { useMutate } from "../hooks/useMutate.ts"; import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts"; @@ -42,20 +49,38 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { const [draft, setDraft] = useState(() => purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); - const errors = validatePurchase(draft); + // "many" is only meaningful when adding — editing an existing purchase edits + // exactly one, so the toggle is hidden in that case. + const [mode, setMode] = useState<"one" | "many">("one"); + const [bulk, setBulk] = useState(() => + emptyBulkDraft(monthId, activePosts[0]?.id ?? ""), + ); + const bulkMode = !purchase && mode === "many"; + + const errors = bulkMode ? validateBulk(bulk) : validatePurchase(draft); function reset() { setDraft( purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); + setBulk(emptyBulkDraft(monthId, activePosts[0]?.id ?? "")); } function save() { if (errors.length > 0) return; - mutate((data) => { - if (purchase) updatePurchase(data, purchase.id, toPurchase(draft)); - else addPurchase(data, toPurchase(draft)); - }); + if (bulkMode) { + // One mutate for the whole batch: a single write, one queue entry, and no + // way to half-commit a shopping trip. + const created = toPurchases(bulk); + mutate((data) => { + for (const p of created) addPurchase(data, p); + }); + } else { + mutate((data) => { + if (purchase) updatePurchase(data, purchase.id, toPurchase(draft)); + else addPurchase(data, toPurchase(draft)); + }); + } setOpen(false); if (!purchase) reset(); } @@ -71,10 +96,92 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { {trigger} - {purchase ? "Edit purchase" : "Add purchase"} + + {purchase ? "Edit purchase" : bulkMode ? "Add many purchases" : "Add purchase"} + + {!purchase && ( +
+ {(["one", "many"] as const).map((option) => ( + + ))} +
+ )} +
+ {bulkMode ? ( + <> +
+
+ + { + const date = event.target.value; + setBulk((b) => ({ ...b, date })); + }} + /> +
+
+ + +
+
+ + +
+
+ + + +

+ Each line is saved as its own purchase in the post above. Need a + split or a payment plan on one of them? Save it here, then open + it from the month view. +

+ + ) : ( + <>
@@ -130,6 +237,8 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { + + )} {purchase?.schedule && !purchase.schedule.cancelledFromMonth && (