fix(booth): a partial ask answer is recorded, not refused

Operator: the form failed when a question was left blank. Refusing the whole
submission over one blank threw away the picks that were made, and the HTML
`required` on the radios blocked it in the browser before the server saw it.

- answered questions recorded; blank ones land in `unanswered`; `complete` says
  whether the set is finished; a blank question carrying a note keeps the note
- `required` dropped from both templates so the browser cannot block a partial
- refused only when there is no pick anywhere AND no notes (a 400 — that would
  flip an open ask to answered with no decision recorded); a choice outside the
  option list is still an error
- new ◐ partial state with an n/N count; skipped questions render as skipped
- README + global CLAUDE.md tell reading sessions to check `complete`
- 154 tests; v0.1.15
This commit is contained in:
vh
2026-09-09 15:05:18 -07:00
parent 6d36119bc5
commit 88d3cf436e
7 changed files with 179 additions and 35 deletions
+20 -1
View File
@@ -141,8 +141,14 @@ EOF
# "choice_index", "label", "notes"}, "r2": {...}, "seed": {...}}, "notes", "answered_at", "answered_by"}
```
Both shapes also carry **`unanswered`** (the question keys left blank; `[null]`
for a blank single-question ask) and **`complete`** (false until every question
has a pick). A reading session should check `complete` before acting on a
multi-question answer, and treat a key in `unanswered` as "not decided", never
as "declined".
The single-question answer: `{"stem", "prompt", "choice", "choice_index", "label", "notes",
"answered_at", "answered_by"}` — `choice` is the option id (the label itself
"unanswered", "complete", "answered_at", "answered_by"}` — `choice` is the option id (the label itself
for string options), `choice_index` its 0-based position, `answered_by` the
client address. `POST /b/<name>/answer` is what the form submits — fields `ask` plus
`choice` / `notes` (single) or `choice.<key>` / `notes.<key>` / `notes` (multi);
@@ -156,6 +162,19 @@ Rules of the primitive:
`questions` when the picks belong together and should land as one answer.
- **Re-answering overwrites.** The sidecar is the *current* answer, not a log.
The page shows the recorded answer with a collapsed *change answer* form.
- **Blanks are legal — a partial answer is recorded, not refused.** Leaving a
question alone is a real outcome ("none of these", "not listened to yet"), and
refusing the whole submission over one blank threw away the picks that WERE
made. So every answered question is recorded, every blank one lands in
`unanswered`, and `complete` says whether the set is finished. The radios carry
no HTML `required`, so the browser does not block the submit either. A question
left blank but carrying a note keeps the note (`choice: null`). The one refusal
is a submission with **no pick anywhere and no notes** — a 400, because it would
flip an open ask to "answered" while recording no decision, which is worse for
the reading session than leaving it open. A choice that is not in the option
list is still an error: that is a broken form, not a skipped question.
Partially-answered asks show as `◐ partial` with an `n/N` count; re-submitting
fills in the rest.
- **Open asks are flagged** — an amber `? N asks` badge on the index card and in
the booth header — so a waiting question is visible from the front page.
- **A broken ask is shown as broken**, not hidden: if the JSON does not