feat(ui): add 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.

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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-02 01:44:55 +02:00
1 parent 417adb0d85
commit da87bff9c3
4 files changed
+466 -6

No files matched your search

+136
View File
@@ -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> = {}): 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([]);
});
});