From ae34f9519dd3f21e93757c313e31ee29d07ea7cc Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 2 Sep 2026 01:57:58 +0200 Subject: [PATCH] feat(ui): keyboard-first bulk rows, optional day, notes per purchase Implements docs/superpowers/specs/2026-09-02-fast-entry-refinements-design.md. Three changes shipped together because they share one tab order. Bulk rows are description -> amount -> note, and nothing else is a tab stop. The list always ends in exactly one blank row, appended as soon as the last row is filled, so Tab flows into a new line with no special case and new rows appear on touch too. The Add-line button is gone; the per-row remove button is now a hover/focus x with tabIndex={-1}, and Backspace in an empty description removes the row and lands on the previous row's note. Purchases no longer demand a day. `date` accepts "2026-09" as well as "2026-09-14"; entry defaults to the month, with an opt-in "set exact date" link. No migration: monthOf() already parsed both shapes, verified before choosing this over splitting the field into month + optional day, which would have meant a schema migration in the browser against the user's only copy of their data for an identical outcome. Notes are an optional field on Purchase, in both entry forms and shown beside the description in the month lists. Empty notes are omitted rather than stored as "", so absent stays absent. Also closes a pre-existing gap the spec surfaced: the JSON importer never validated purchase.date, so a malformed date passed import and threw later inside the balance fold. It is now rejected at the boundary, with out-of-range months and days covered. A row holding only a note is deliberately NOT blank: it asks for the missing description and amount rather than being silently discarded. Keyboard behaviour - tab traversal, Backspace-to-remove, focus after append - cannot be verified in this environment and needs confirming in a browser. Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/months.test.ts | 14 ++++ src/domain/types.ts | 8 +- src/export/json.test.ts | 28 +++++++ src/export/json.ts | 9 ++ src/ui/bulkEntry.test.ts | 55 ++++++++++++- src/ui/bulkEntry.ts | 36 +++++++- src/ui/components/BulkLines.tsx | 118 +++++++++++++++------------ src/ui/components/PurchaseDialog.tsx | 74 +++++++++++++++-- src/ui/purchaseForm.test.ts | 29 ++++++- src/ui/purchaseForm.ts | 8 +- src/ui/routes/MonthRoute.tsx | 7 +- src/ui/routes/PostMonthRoute.tsx | 7 +- 12 files changed, 319 insertions(+), 74 deletions(-) diff --git a/src/domain/months.test.ts b/src/domain/months.test.ts index c3da79c..b04d05e 100644 --- a/src/domain/months.test.ts +++ b/src/domain/months.test.ts @@ -94,3 +94,17 @@ describe("month range validation", () => { expect(monthOf("2026-12-31")).toBe("2026-12"); }); }); + +// The optional-day feature stores "YYYY-MM" when a purchase has no specific +// day. monthOf already handled that incidentally; this pins it, because the +// whole approach depends on it rather than on a migration. +test("monthOf accepts a month-only date as well as a full one", () => { + expect(monthOf("2026-09-14")).toBe("2026-09"); + expect(monthOf("2026-09")).toBe("2026-09"); +}); + +test("monthOf still rejects out-of-range and malformed month-only values", () => { + expect(() => monthOf("2026-13")).toThrow(/2026-13/); + expect(() => monthOf("2026-00")).toThrow(/2026-00/); + expect(() => monthOf("2026-9")).toThrow(/2026-9/); +}); diff --git a/src/domain/types.ts b/src/domain/types.ts index 1f37e08..93a7e5c 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -15,7 +15,11 @@ export interface Money { /** "YYYY-MM". Never a Date. */ export type MonthId = string; -/** "YYYY-MM-DD". Never a Date. */ +/** + * "YYYY-MM-DD", or "YYYY-MM" when a purchase has no specific day — the app is + * about monthly spending, so a day is optional. `monthOf()` parses both. + * Never a Date. + */ export type IsoDate = string; export type PostId = string; export type PurchaseId = string; @@ -63,6 +67,8 @@ export interface Purchase { id: PurchaseId; date: IsoDate; description: string; + /** Optional longer context, alongside the short `description` label. */ + note?: string; /** In the purchase's own currency. */ total: Money; splitMode: "percent" | "fixed"; diff --git a/src/export/json.test.ts b/src/export/json.test.ts index f493b9c..0766f68 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -116,3 +116,31 @@ test("describeDataset counts what an import would replace", () => { test("exportFilename is stable and sortable", () => { expect(exportFilename("2026-09")).toBe("budget-2026-09.json"); }); + +describe("purchase dates", () => { + test("accepts a full date and a month-only date", () => { + const data = populated(); + data.purchases[0]!.date = "2026-01-15"; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + data.purchases[0]!.date = "2026-01"; + expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow(); + }); + + test("rejects a malformed date instead of letting the fold throw later", () => { + const data = populated(); + data.purchases[0]!.date = "not-a-date"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/not-a-date/); + }); + + test("rejects an out-of-range month in a purchase date", () => { + const data = populated(); + data.purchases[0]!.date = "2026-13-01"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13-01/); + }); + + test("rejects an out-of-range day", () => { + const data = populated(); + data.purchases[0]!.date = "2026-01-32"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-01-32/); + }); +}); diff --git a/src/export/json.ts b/src/export/json.ts index 6ebc8de..8566f5b 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -29,6 +29,12 @@ export function exportFilename(monthId: MonthId): string { // month inside the balance fold (the same defect fixed earlier in // src/domain/months.ts). This is the import path that made it reachable. const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; +/** + * A purchase date is "YYYY-MM-DD", or "YYYY-MM" when it has no specific day. + * Validated here so a malformed date is rejected at the boundary rather than + * throwing out of `monthOf()` deep inside the balance fold. + */ +const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/; function requireCurrency(value: unknown, where: string): Currency { if (!CURRENCIES.includes(value as Currency)) { @@ -96,6 +102,9 @@ export function parseDatasetJson(text: string): Dataset { const label = `purchase "${purchase.description}"`; requireCurrency(purchase.total.currency, label); + if (!PURCHASE_DATE.test(purchase.date)) { + throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`); + } if (purchase.splits.length === 0) { throw new ImportValidationError(`${label} has no splits; at least one split is required`); } diff --git a/src/ui/bulkEntry.test.ts b/src/ui/bulkEntry.test.ts index d53ec57..553dda6 100644 --- a/src/ui/bulkEntry.test.ts +++ b/src/ui/bulkEntry.test.ts @@ -1,5 +1,6 @@ import { test, expect, describe } from "bun:test"; import { + withTrailingBlank, emptyBulkDraft, isBlankLine, filledLines, @@ -13,15 +14,15 @@ import { function draft(overrides: Partial = {}): BulkDraft { return { ...emptyBulkDraft("2026-09", "food"), ...overrides }; } -const line = (description: string, amount: number) => ({ description, amount }); +const line = (description: string, amount: number, note = "") => ({ description, amount, note }); 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.date).toBe("2026-09"); expect(d.postId).toBe("food"); expect(d.currency).toBe("DKK"); - expect(d.lines).toEqual([{ description: "", amount: 0 }]); + expect(d.lines).toEqual([{ description: "", amount: 0, note: "" }]); }); }); @@ -134,3 +135,51 @@ describe("toPurchases", () => { expect(toPurchases(draft())).toEqual([]); }); }); + +describe("notes and blank rows across three columns", () => { + const l = (description: string, amount: number, note = "") => ({ description, amount, note }); + + test("a row is blank only when description, amount AND note are all empty", () => { + expect(isBlankLine(l("", 0, ""))).toBe(true); + expect(isBlankLine(l("", 0, " "))).toBe(true); + expect(isBlankLine(l("Milk", 0))).toBe(false); + expect(isBlankLine(l("", 25))).toBe(false); + expect(isBlankLine(l("", 0, "for the party"))).toBe(false); + }); + + test("a note-only row is not silently dropped — it asks for both missing fields", () => { + const errors = validateBulk(draft({ lines: [l("", 0, "for the party")] })); + expect(errors.some((e) => e.includes("1") && /description/i.test(e))).toBe(true); + expect(errors.some((e) => e.includes("1") && /amount/i.test(e))).toBe(true); + }); + + test("notes reach the stored purchases, and empty ones are omitted", () => { + const ps = toPurchases(draft({ lines: [l("Milk", 25, "oat"), l("Bread", 30)] })); + expect(ps[0]!.note).toBe("oat"); + expect("note" in ps[1]!).toBe(false); + }); +}); + +describe("withTrailingBlank", () => { + const l = (description: string, amount: number, note = "") => ({ description, amount, note }); + + test("appends a blank row when the last row has been filled in", () => { + const d = withTrailingBlank(draft({ lines: [l("Milk", 25)] })); + expect(d.lines).toHaveLength(2); + expect(isBlankLine(d.lines[1]!)).toBe(true); + }); + + test("leaves a list that already ends blank alone — never two trailing blanks", () => { + const d = withTrailingBlank(draft({ lines: [l("Milk", 25), l("", 0)] })); + expect(d.lines).toHaveLength(2); + }); + + test("an all-blank list keeps exactly one row", () => { + expect(withTrailingBlank(draft()).lines).toHaveLength(1); + }); + + test("trailing blanks beyond the first are trimmed", () => { + const d = withTrailingBlank(draft({ lines: [l("Milk", 25), l("", 0), l("", 0), l("", 0)] })); + expect(d.lines).toHaveLength(2); + }); +}); diff --git a/src/ui/bulkEntry.ts b/src/ui/bulkEntry.ts index 11aef92..30533fd 100644 --- a/src/ui/bulkEntry.ts +++ b/src/ui/bulkEntry.ts @@ -15,6 +15,8 @@ import { toPurchase, type PurchaseDraft } from "./purchaseForm.ts"; export interface BulkLine { description: string; amount: number; + /** Optional longer context, alongside the short description label. */ + note: string; } export interface BulkDraft { @@ -26,16 +28,41 @@ export interface BulkDraft { export function emptyBulkDraft(monthId: MonthId, postId: string): BulkDraft { return { - date: `${monthId}-01`, + // No day: the app is about monthly spending. "2026-09" is a legal IsoDate. + date: monthId, postId, currency: "DKK", - lines: [{ description: "", amount: 0 }], + lines: [blankLine()], }; } -/** A row the user has not touched. Trailing blanks are ignored, never errors. */ +export function blankLine(): BulkLine { + return { description: "", amount: 0, note: "" }; +} + +/** + * A row the user has not touched at all. Trailing blanks are ignored, never + * errors. A row carrying ONLY a note is deliberately not blank: silently + * discarding something the user typed is worse than asking them to finish it. + */ export function isBlankLine(line: BulkLine): boolean { - return line.description.trim() === "" && line.amount === 0; + return line.description.trim() === "" && line.amount === 0 && line.note.trim() === ""; +} + +/** + * Keeps the list ending in exactly one blank row, so filling the last row opens + * a fresh one. That is what lets Tab flow into a new line with no special case, + * and it works on touch, where there is no Tab key and hence no Add-line button. + */ +export function withTrailingBlank(draft: BulkDraft): BulkDraft { + const lines = [...draft.lines]; + while (lines.length > 1 && isBlankLine(lines[lines.length - 1]!) && isBlankLine(lines[lines.length - 2]!)) { + lines.pop(); + } + if (lines.length === 0 || !isBlankLine(lines[lines.length - 1]!)) { + lines.push(blankLine()); + } + return { ...draft, lines }; } export function filledLines(draft: BulkDraft): BulkLine[] { @@ -54,6 +81,7 @@ export function lineToDraft(draft: BulkDraft, line: BulkLine): PurchaseDraft { return { date: draft.date, description: line.description, + note: line.note, amount: line.amount, currency: draft.currency, splitMode: "percent", diff --git a/src/ui/components/BulkLines.tsx b/src/ui/components/BulkLines.tsx index 15e4097..1c5ab64 100644 --- a/src/ui/components/BulkLines.tsx +++ b/src/ui/components/BulkLines.tsx @@ -1,9 +1,14 @@ 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"; +import { + bulkTotal, + isBlankLine, + withTrailingBlank, + type BulkDraft, + type BulkLine, +} from "../bulkEntry.ts"; interface Props { draft: BulkDraft; @@ -11,31 +16,31 @@ interface Props { } /** - * 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. + * The row list for fast entry: description, amount, note — and nothing else in + * the tab order, so Tab walks the fields and only the fields. + * + * The list always ends in one blank row, appended as soon as the last row is + * filled, so Tab flows into a new line with no special case and new rows appear + * on touch devices too. That is why there is no "Add line" button. */ export function BulkLines({ draft, onChange }: Props) { - const amountRefs = useRef>([]); + const descriptionRefs = useRef>([]); + const noteRefs = 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 update(index: number, changes: Partial) { + onChange( + withTrailingBlank({ + ...draft, + lines: draft.lines.map((line, i) => (i === index ? { ...line, ...changes } : line)), + }), + ); } function removeLine(index: number) { - const lines = draft.lines.filter((_, i) => i !== index); - onChange({ ...draft, lines: lines.length > 0 ? lines : [{ description: "", amount: 0 }] }); + const remaining = draft.lines.filter((_, i) => i !== index); + onChange(withTrailingBlank({ ...draft, lines: remaining })); + // Land on the previous row's last field, which is where the user came from. + if (index > 0) requestAnimationFrame(() => noteRefs.current[index - 1]?.focus()); } const filledCount = draft.lines.filter((line) => !isBlankLine(line)).length; @@ -45,74 +50,83 @@ export function BulkLines({ draft, onChange }: Props) {
- Enter on an amount starts the next line + Tab through the fields; a new line opens as you fill the last one
{draft.lines.map((line, index) => ( -
+
{index + 1} + { + descriptionRefs.current[index] = el; + }} className="h-8 flex-1" placeholder="What was it?" value={line.description} onChange={(event) => { const description = event.target.value; - updateLine(index, { description }); + update(index, { description }); }} onKeyDown={(event) => { - if (event.key === "Enter") { + // Backspace in an already-empty description removes the row. + if ( + event.key === "Backspace" && + line.description === "" && + draft.lines.length > 1 + ) { event.preventDefault(); - amountRefs.current[index]?.focus(); + removeLine(index); } }} /> + { - amountRefs.current[index] = el; - }} - className="font-money h-8 w-28" + className="font-money h-8 w-24" 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(); - } + update(index, { amount }); }} /> - + × +
))}
-
- - - {filledCount} line{filledCount === 1 ? "" : "s"} ·{" "} - {formatMoney(bulkTotal(draft), draft.currency)} - -
+

+ {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 c74e303..9448637 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -122,16 +122,32 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { <>
- + 7 ? "date" : "text"} + readOnly={bulk.date.length <= 7} + className={bulk.date.length > 7 ? undefined : "font-money"} value={bulk.date} onChange={(event) => { const date = event.target.value; setBulk((b) => ({ ...b, date })); }} /> +
@@ -184,13 +200,40 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { <>
- - setDraft({ ...draft, date: event.target.value })} - /> + + {draft.date.length > 7 ? ( + <> + { + const date = event.target.value; + setDraft((d) => ({ ...d, date })); + }} + /> + + + ) : ( + <> + + + + )}
@@ -234,6 +277,19 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
+
+ + { + const note = event.target.value; + setDraft((d) => ({ ...d, note })); + }} + /> +
+ diff --git a/src/ui/purchaseForm.test.ts b/src/ui/purchaseForm.test.ts index 1053a60..b8d7abe 100644 --- a/src/ui/purchaseForm.test.ts +++ b/src/ui/purchaseForm.test.ts @@ -17,9 +17,11 @@ function draft(overrides: Partial = {}): PurchaseDraft { } describe("emptyDraft", () => { - test("starts on the first of the given month with one full-weight split", () => { + test("starts on the month itself with one full-weight split", () => { + // The app is about monthly spending, so a new purchase carries no day + // until the user asks for one. "2026-09" is a legal IsoDate here. const d = emptyDraft("2026-09", "food"); - expect(d.date).toBe("2026-09-01"); + expect(d.date).toBe("2026-09"); expect(d.splitMode).toBe("percent"); expect(d.splits).toEqual([{ postId: "food", value: 100, absorbsRemainder: true }]); expect(d.plan).toBeNull(); @@ -199,3 +201,26 @@ describe("finance plans", () => { function sliceSum(draft: PurchaseDraft): number { return draft.plan!.slices.reduce((sum, s) => sum + s.amount.amount, 0); } + +describe("notes", () => { + test("a new draft starts with an empty note", () => { + expect(emptyDraft("2026-09", "food").note).toBe(""); + }); + + test("a note round-trips through toPurchase and fromPurchase", () => { + const d = draft({ note: "Anna's birthday" }); + const stored = toPurchase(d); + expect(stored.note).toBe("Anna's birthday"); + expect(fromPurchase({ ...stored, id: "x" }).note).toBe("Anna's birthday"); + }); + + test("an empty note is omitted from the stored purchase, not written as \"\"", () => { + expect("note" in toPurchase(draft({ note: "" }))).toBe(false); + expect("note" in toPurchase(draft({ note: " " }))).toBe(false); + }); + + test("a purchase with no note reads back as an empty string", () => { + const stored = toPurchase(draft()); + expect(fromPurchase({ ...stored, id: "x" }).note).toBe(""); + }); +}); diff --git a/src/ui/purchaseForm.ts b/src/ui/purchaseForm.ts index a7388e5..70dcfb0 100644 --- a/src/ui/purchaseForm.ts +++ b/src/ui/purchaseForm.ts @@ -23,6 +23,7 @@ export interface PlanDraft { export interface PurchaseDraft { date: IsoDate; description: string; + note: string; amount: number; currency: Currency; splitMode: "percent" | "fixed"; @@ -32,8 +33,9 @@ export interface PurchaseDraft { export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft { return { - date: `${monthId}-01`, + date: monthId, description: "", + note: "", amount: 0, currency: "DKK", splitMode: "percent", @@ -78,9 +80,12 @@ export function validatePurchase(draft: PurchaseDraft): string[] { } export function toPurchase(draft: PurchaseDraft): Omit { + const note = draft.note.trim(); return { date: draft.date, description: draft.description.trim(), + // Omitted rather than stored as "" so an absent note stays absent. + ...(note === "" ? {} : { note }), total: { amount: draft.amount, currency: draft.currency }, splitMode: draft.splitMode, splits: draft.splits.map((s) => ({ ...s })), @@ -144,6 +149,7 @@ export function fromPurchase(purchase: Purchase): PurchaseDraft { return { date: purchase.date, description: purchase.description, + note: purchase.note ?? "", amount: purchase.total.amount, currency: purchase.total.currency, splitMode: purchase.splitMode, diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index cf0cf0b..20f8417 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -96,7 +96,12 @@ export function MonthRoute() { const slice = sliceAmountForMonth(purchase, monthId)!; return (
  • - {purchase.description} + + {purchase.description} + {purchase.note && ( + {purchase.note} + )} + {purchase.schedule && ( financed )} diff --git a/src/ui/routes/PostMonthRoute.tsx b/src/ui/routes/PostMonthRoute.tsx index 115afa8..d021408 100644 --- a/src/ui/routes/PostMonthRoute.tsx +++ b/src/ui/routes/PostMonthRoute.tsx @@ -92,7 +92,12 @@ export function PostMonthRoute() {
      {thisMonth.map(({ purchase, amount }) => (
    • - {purchase.description} + + {purchase.description} + {purchase.note && ( + {purchase.note} + )} + {purchase.splits.length > 1 && ( split )}