docs: spec fast-entry refinements (tab flow, optional day, notes)

Three entry-form changes specified together because they share the same tab
order: keyboard-driven bulk rows, purchases that no longer demand a day, and
an optional note per purchase.

Records why the date change widens IsoDate rather than splitting it into
month + optional day: the split is the better model but changes a type every
layer depends on and needs a migration run in the browser against the user's
only copy of their data, for an identical user-visible outcome. Verified that
monthOf() already parses both date shapes and still rejects out-of-range
months.

Time-versioned allocation rules are explicitly out of scope and get their own
cycle.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 01:53:43 +02:00
co-authored by Claude Opus 5
parent da87bff9c3
commit 6924a593fb
@@ -0,0 +1,176 @@
# Fast entry refinements — Design
**Date:** 2026-09-02
**Status:** Awaiting review
**Supersedes nothing.** Extends `2026-09-01-budget-app-design.md`; every invariant
in that document still holds.
## Purpose
Three changes to how purchases are entered, driven by using the app:
1. Entering many lines should be keyboard-driven. Today the row list puts a
*remove* button between every amount and the next description, so tabbing
costs twice the stops it should, and adding a row needs a mouse.
2. The app should be about monthly spending, not a daily log. Today every
purchase demands an exact date during entry.
3. A purchase should carry a note — longer context than its short label.
They are specified together because all three change the same two entry forms
and, in particular, the same tab order. Building them separately would mean
writing that tab order twice.
## Non-goals
Deliberately excluded. Each is a separate cycle.
- **Time-versioned allocation rules** (a rule effective from a month onward).
Architectural: it changes `allocationFor`, which the whole rollover fold runs
through, and needs the project's first schema migration. Specified separately.
- **Per-line splits and finance plans in fast entry.** A line needing either is
saved plain and edited from the month view.
- **Any schema migration.** Both model changes here are backward compatible by
construction; see "Data model".
## Decisions and why
### Dates: widen the field rather than restructure it
`Purchase.date` keeps the type `IsoDate`, but the string may now be either
`"YYYY-MM-DD"` or `"YYYY-MM"`. A purchase with no day belongs to its month.
Considered and rejected: splitting into `month: MonthId` plus `day?: number`.
That is the more honest model and would be the right call in a greenfield
design. It was rejected because it changes a type every layer depends on and
requires a migration executed in the browser against the user's only copy of
their financial data. The user-visible outcome is identical. If the model
becomes a problem, it is a safe refactor later, behind better coverage.
This works because `monthOf()` already parses both shapes: it matches the first
seven characters and validates the month is 01–12. Verified against the current
implementation before adopting this approach:
| input | result |
|---|---|
| `"2026-09-14"` | `"2026-09"` |
| `"2026-09"` | `"2026-09"` |
| `"2026-13"`, `"2026-00"`, `"2026-9"`, `"nonsense"` | throws |
The month-range guard added earlier therefore already protects the month-only
form. This behaviour is currently incidental and must be pinned by a test, since
the whole approach depends on it.
### Notes: an optional field, not a replacement
`description` stays the short scannable label the month list renders. `note` is
optional free text alongside it. Making it optional is what keeps this
migration-free: absent is a legal value for every existing purchase.
### A row holding only a note is not blank
Blank rows are ignored on save. A row with a note but no description or amount
is NOT blank: it surfaces a validation message asking for the missing fields.
Silently discarding something the user typed is worse than an error.
## Design
### Entry flow (fast entry)
- Row columns become **description → amount → note**. These are the only tab
stops in a row.
- The list always ends in exactly one blank row. Filling the last row appends a
fresh blank below it, so Tab flows into a new line with no special-casing —
and new rows appear on touch devices, which have no Tab key. The **Add line**
button is therefore removed.
- **Backspace** in an empty description field removes that row and moves focus
to the previous row's **note** field, which is that row's last tab stop. The
list never shrinks below one row: removing the only remaining row clears it
instead of deleting it.
- A **×** control fades in on row hover or focus for pointer and touch users. It
carries `tabIndex={-1}` so it is clickable but never a tab stop — restoring
the tab order is the point of this change. It removes a row under the same
rule as Backspace, including the never-below-one-row floor.
- The running line count and total stay.
### Dates
- Both entry forms default to the month being viewed, with **no day**.
- A specific day is set by a **"set exact date" link** beneath the month, which
reveals a native `<input type="date">` when clicked. It is a link rather than
an always-present field so the day is opt-in, and it is not in the row tab
order. Once a day is set, the field stays visible for that purchase.
- Display shows the day only when the stored value has one; otherwise the month.
- Sorting and grouping are unaffected: everything already derives the month via
`monthOf()`.
### Notes
- Present in the single purchase dialog and as the third bulk-row column.
- Rendered in the month view's purchase list when non-empty, subordinate to the
description.
- Never required.
## Data model
```ts
// src/domain/types.ts
/** "YYYY-MM-DD", or "YYYY-MM" when the purchase has no specific day. */
type IsoDate = string;
interface Purchase {
// ...unchanged...
/** Optional longer context, alongside the short `description` label. */
note?: string;
}
```
No `schemaVersion` bump. Both changes are supersets of what is already stored:
every existing purchase has a full date (still legal) and no note (legal).
## Validation
`parseDatasetJson` must accept a `Purchase.date` of either shape and continue to
reject anything else — including a month outside 01–12, which the existing
`MONTH_ID` tightening already guards for month ids and must now also guard for
the month-only date form.
Fast-entry validation mirrors `validatePurchase` in what it deliberately does
**not** reject: no rejection on magnitude, negative amounts (a refund is a
normal line), splits that do not sum, or going over budget.
## Testing
- `monthOf()` accepts `"2026-09"` as well as `"2026-09-14"`, and still throws on
a malformed or out-of-range value. This is currently incidental behaviour that
the date change depends on; the test makes it a guarantee.
- `note` round-trips through `toPurchase`/`fromPurchase` and through
`toPurchases` for bulk lines.
- Blank-row logic accounts for the third column. A row is blank only when
description, amount AND note are all empty. Each partially filled shape
produces a specific, row-numbered message:
- description, no amount -> `Line N: enter an amount other than zero.`
- amount, no description -> `Line N: give it a description.`
- note only -> both of the above, since it has neither.
- Appending behaviour: filling the last row yields exactly one new trailing
blank, never two.
- JSON import accepts both date shapes and still rejects garbage.
- The existing suite stays green, and the `eventCapture` guard covers the new
handlers.
Keyboard behaviour — Tab order, Backspace-to-remove, focus after append — cannot
be verified in this environment: there is no browser, and happy-dom does not
model focus and tab traversal faithfully. These require the user to confirm.
## Files
| File | Change |
|---|---|
| `src/domain/types.ts` | `note?`, `IsoDate` doc comment |
| `src/domain/months.test.ts` | pin month-only parsing |
| `src/ui/purchaseForm.ts` | `note` in the draft and both conversions |
| `src/ui/bulkEntry.ts` | `note` per line; blank-row rule; trailing-blank append |
| `src/ui/components/BulkLines.tsx` | third column, tab order, Backspace, hover × |
| `src/ui/components/PurchaseDialog.tsx` | note field; date de-emphasis |
| `src/export/json.ts` | accept both date shapes |
| `src/export/ods.ts` | date column renders either shape |
| `src/ui/routes/MonthRoute.tsx`, `PostMonthRoute.tsx` | show note; show day only when present |