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 && (