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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6924a593fb
commit
ae34f9519d
@@ -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<Array<HTMLInputElement | null>>([]);
|
||||
const descriptionRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
const noteRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
function updateLine(index: number, changes: Partial<BulkDraft["lines"][number]>) {
|
||||
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<BulkLine>) {
|
||||
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) {
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Lines</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Enter on an amount starts the next line
|
||||
Tab through the fields; a new line opens as you fill the last one
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto pr-1">
|
||||
{draft.lines.map((line, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div key={index} className="group flex items-center gap-2">
|
||||
<span className="w-5 shrink-0 text-right text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<Input
|
||||
ref={(el) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Input
|
||||
ref={(el) => {
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
|
||||
<Input
|
||||
ref={(el) => {
|
||||
noteRefs.current[index] = el;
|
||||
}}
|
||||
className="h-8 w-40"
|
||||
placeholder="Note (optional)"
|
||||
value={line.note}
|
||||
onChange={(event) => {
|
||||
const note = event.target.value;
|
||||
update(index, { note });
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove line ${index + 1}`}
|
||||
className="w-5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 hover:text-[var(--overspend)]"
|
||||
onClick={() => removeLine(index)}
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => addLine(draft.lines.length)}>
|
||||
Add line
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
{filledCount} line{filledCount === 1 ? "" : "s"} ·{" "}
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft), draft.currency)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{filledCount} line{filledCount === 1 ? "" : "s"} ·{" "}
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft), draft.currency)}</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,16 +122,32 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bulk-date">Date</Label>
|
||||
<Label htmlFor="bulk-date">
|
||||
{bulk.date.length > 7 ? "Date" : "Month"}
|
||||
</Label>
|
||||
<Input
|
||||
id="bulk-date"
|
||||
type="date"
|
||||
type={bulk.date.length > 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 }));
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline"
|
||||
onClick={() =>
|
||||
setBulk((b) => ({
|
||||
...b,
|
||||
date: b.date.length > 7 ? b.date.slice(0, 7) : `${b.date}-01`,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{bulk.date.length > 7 ? "use the month only" : "set exact date"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bulk-post">Post</Label>
|
||||
@@ -184,13 +200,40 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchase-date">Date</Label>
|
||||
<Input
|
||||
id="purchase-date"
|
||||
type="date"
|
||||
value={draft.date}
|
||||
onChange={(event) => setDraft({ ...draft, date: event.target.value })}
|
||||
/>
|
||||
<Label htmlFor="purchase-date">
|
||||
{draft.date.length > 7 ? "Date" : "Month"}
|
||||
</Label>
|
||||
{draft.date.length > 7 ? (
|
||||
<>
|
||||
<Input
|
||||
id="purchase-date"
|
||||
type="date"
|
||||
value={draft.date}
|
||||
onChange={(event) => {
|
||||
const date = event.target.value;
|
||||
setDraft((d) => ({ ...d, date }));
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline"
|
||||
onClick={() => setDraft((d) => ({ ...d, date: d.date.slice(0, 7) }))}
|
||||
>
|
||||
use the month only
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input id="purchase-date" value={draft.date} readOnly className="font-money" />
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground underline"
|
||||
onClick={() => setDraft((d) => ({ ...d, date: `${d.date}-01` }))}
|
||||
>
|
||||
set exact date
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchase-description">Description</Label>
|
||||
@@ -234,6 +277,19 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="purchase-note">Note (optional)</Label>
|
||||
<Input
|
||||
id="purchase-note"
|
||||
placeholder="Anything worth remembering about this one"
|
||||
value={draft.note}
|
||||
onChange={(event) => {
|
||||
const note = event.target.value;
|
||||
setDraft((d) => ({ ...d, note }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SplitEditor draft={draft} posts={activePosts} onChange={setDraft} />
|
||||
|
||||
<PlanEditor draft={draft} onChange={setDraft} />
|
||||
|
||||
Reference in New Issue
Block a user