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 )}