Files
budget2.0/src/ui/purchaseForm.ts
T
Lucas WintherandClaude Opus 5 ae34f9519d 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) <noreply@anthropic.com>
2026-09-02 01:57:58 +02:00

168 lines
4.7 KiB
TypeScript

import { roundMoney } from "../domain/money.ts";
import { equalSlices, slicesBalance } from "../domain/plans.ts";
import type {
Currency,
IsoDate,
MonthId,
Purchase,
ScheduleSlice,
} from "../domain/types.ts";
export interface SplitDraft {
postId: string;
value: number;
absorbsRemainder: boolean;
}
export interface PlanDraft {
startMonth: MonthId;
slices: ScheduleSlice[];
cancelledFromMonth?: MonthId;
}
export interface PurchaseDraft {
date: IsoDate;
description: string;
note: string;
amount: number;
currency: Currency;
splitMode: "percent" | "fixed";
splits: SplitDraft[];
plan: PlanDraft | null;
}
export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft {
return {
date: monthId,
description: "",
note: "",
amount: 0,
currency: "DKK",
splitMode: "percent",
splits: [{ postId, value: 100, absorbsRemainder: true }],
plan: null,
};
}
/** What the split editor must show: how far the parts are from the whole. */
export function splitBalance(draft: PurchaseDraft): number {
const target = draft.splitMode === "percent" ? 100 : draft.amount;
const sum = draft.splits.reduce((total, split) => total + split.value, 0);
return roundMoney(target - sum, draft.currency);
}
export function validatePurchase(draft: PurchaseDraft): string[] {
const errors: string[] = [];
if (draft.description.trim() === "") {
errors.push("Give the purchase a description.");
}
if (draft.amount === 0) {
errors.push("Enter an amount other than zero.");
}
if (draft.splits.length === 0) {
errors.push("Add at least one post to split across.");
}
if (draft.splits.filter((s) => s.absorbsRemainder).length !== 1) {
errors.push("Choose exactly one post to absorb rounding.");
}
const postIds = draft.splits.map((s) => s.postId);
if (new Set(postIds).size !== postIds.length) {
errors.push("Each post can only appear once.");
}
if (postIds.some((id) => id === "")) {
errors.push("Choose a post for every split.");
}
// Deliberately absent: any check on whether splits balance, or whether the
// purchase pushes a post over budget. Both are permitted by design.
return errors;
}
export function toPurchase(draft: PurchaseDraft): Omit<Purchase, "id"> {
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 })),
schedule: draft.plan
? {
slices: draft.plan.slices.map((s) => ({ ...s, amount: { ...s.amount } })),
...(draft.plan.cancelledFromMonth
? { cancelledFromMonth: draft.plan.cancelledFromMonth }
: {}),
}
: null,
};
}
export function withPlan(
draft: PurchaseDraft,
startMonth: MonthId,
months: number,
): PurchaseDraft {
return {
...draft,
plan: {
startMonth,
slices: equalSlices({ amount: draft.amount, currency: draft.currency }, startMonth, months),
},
};
}
export function withoutPlan(draft: PurchaseDraft): PurchaseDraft {
return { ...draft, plan: null };
}
export function setSliceAmount(
draft: PurchaseDraft,
index: number,
amount: number,
): PurchaseDraft {
if (!draft.plan) return draft;
return {
...draft,
plan: {
...draft.plan,
slices: draft.plan.slices.map((slice, i) =>
i === index ? { ...slice, amount: { ...slice.amount, amount } } : slice,
),
},
};
}
/**
* slices - total, delegated to the domain's slicesBalance so the "sum the
* slices and compare to the total" arithmetic has exactly one implementation.
* Positive means the slices fall short of the purchase total.
*/
export function planBalance(draft: PurchaseDraft): number {
if (!draft.plan) return 0;
return slicesBalance({ amount: draft.amount, currency: draft.currency }, draft.plan.slices);
}
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,
splits: purchase.splits.map((s) => ({ ...s })),
plan: purchase.schedule
? {
startMonth: purchase.schedule.slices[0]?.month ?? "",
slices: purchase.schedule.slices.map((s) => ({ ...s, amount: { ...s.amount } })),
...(purchase.schedule.cancelledFromMonth
? { cancelledFromMonth: purchase.schedule.cancelledFromMonth }
: {}),
}
: null,
};
}