#108 — Mehrzeiliges Einfügen für die Einkaufsliste (wie bei Todo-Listen) #111
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#111
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Story: Mehrzeiliges Einfügen für die Einkaufsliste (wie bei Todo-Listen)
As a Nutzer der Einkaufsliste,
I want to mehrere Zeilen Text auf einmal in das Hinzufügen-Feld einfügen und daraus mehrere Produkte
erzeugen lassen, so wie das bei Todo-Listen schon funktioniert,
so that ich eine Einkaufsliste aus einer Notiz/anderen Quelle nicht Zeile für Zeile einzeln abtippen muss.
Depends on:
#103(Smart-Add muss Menge/Kategorie zuverlässig automatisch erkennen, sonst würde jedeeinzelne Zeile eine Nachfrage auslösen und der Vorteil des Mehrzeilen-Einfügens ginge verloren).
Hintergrund:
TodoInput.tsxs Mehrzeilen-Paste (splitNonBlankLines+MultilinePasteConfirmDialog) iststrukturell einfacher als das, was hier gebraucht wird: Todos haben keine Pflicht-Kategorie-Auflösung pro
Zeile, daher reicht dort eine einzelne Bestätigung ("N Todos anlegen?") gefolgt von einem Bulk-Import. Für die
Einkaufsliste muss dagegen jede Zeile potenziell durch dieselbe Auflösung wie ein einzelnes Smart-Add
(Mengen-Präfix, Kategorie-Wissensbasis — siehe
#103) laufen, und nur dort nachfragen, wo das nicht eindeutigautomatisch geht.
Acceptance criteria:
Todo-Listen zunächst eine Bestätigung gezeigt ("N Produkte anlegen?"), mit derselben
Zeilen-Aufteilungslogik (
splitNonBlankLines: leere Zeilen ignoriert, getrimmt).einzelnes Smart-Add (siehe
#103): Menge und Kategorie werden zuerst automatisch versucht; nur wenn eineZeile nicht eindeutig auflösbar ist, öffnet sich für diese eine Zeile die entsprechende Nachfrage
(Kategorie-Picker bzw. Mengen-Popup), bevor mit der nächsten Zeile fortgefahren wird.
gesamte restliche Verarbeitung — bereits erfolgreich angelegte Produkte aus vorherigen Zeilen bleiben
erhalten, alle noch ausstehenden Zeilen werden verworfen.
dass noch etwas läuft und Escape etwas Sichtbares abbricht.
Out of scope for this story:
Abbruchpunkt hat.
unabhängig verarbeitet und kann sich mit einer vorherigen Zeile aus demselben Einfüge-Vorgang zusammenführen,
wenn beide auf denselben Produktnamen auflösen — das ist bereits das bestehende Mengen-Konsolidierungs-
Verhalten aus
#90/#103, keine neue Logik).design (
108_shopping_multiline_paste_design.md)Design note —
#108Mehrzeiliges Einfügen für die EinkaufslisteNo backend change.
AddOrActivateShoppingProductCommand(#103) already does everything a lineneeds server-side: quantity-prefix stripping + category-knowledge-base resolution. This story is
purely frontend: intercept a multi-line paste in
SmartAddInput, confirm, then drive the existingper-item resolution sequentially, only opening a picker/popup for whatever a given line couldn't
resolve on its own.
Paste interception
Same mechanism as
TodoInput.tsx'shandlePaste(splitNonBlankLines, only intercepted when thefield is empty and clipboard text has ≥2 non-blank lines). Applied to
SmartAddInput's<Input>element — a single-line
<input>still receives the raw multi-line clipboard text viae.clipboardData.getData('text')in thepasteevent, before any native collapsing happens.Reused
MultilinePasteConfirmDialoggets a new optionalnounprop (default'todo') soSmartAddInputcan rendernoun="product"→ "Create N products?" without duplicating the dialog.Declining (Cancel/Escape/backdrop) simply drops the paste — no fallback single-item create.
Unlike
TodoInput, there's no pre-existing "paste multi-line text into this field" behavior topreserve (a single-line
<input>never auto-submitted on paste before this story), so there'snothing to fall back to.
Sequential per-line resolution
Confirming runs a
forloop over the split lines, awaiting one line fully before starting thenext (required for the picker/popup to target the right line, and per the AC's explicit
"sequential, not parallel"). Each line:
AddOrActivateShoppingProductCommand(the same callsmartFallbackAddalready makes fora single item) — this alone resolves both quantity and category for most real input.
categoryIdcame backnull, opens the category picker and awaits a choice.quantityis still falsy, opens the quantity popup (owned byShoppingListPage, shared withthe rest of the page) and awaits it closing.
The local-embedding "Did you mean" product-match / category-match chips (
#82) are not part ofthis path — the story's own background section scopes "dieselbe Auflösung wie ein einzelnes
Smart-Add" to
#103's resolution specifically, and a batch of interactive accept/decline chips perline isn't one of the two Nachfrage types the AC calls out (Kategorie-Picker, Mengen-Popup).
Making the picker/popup awaitable
Both the category picker (local to
SmartAddInput) and the quantity popup (rendered byShoppingListPage, shared withShoppingProductItem's manual-activate click) were fire-and-forgetbefore this story — a click handler set some state and moved on. Batch processing needs to await
the user's decision before continuing to the next line, so both become promise-based:
SmartAddInput:openCategoryPicker(): Promise<ShoppingCategoryId | null>replaces the oldpendingCategoryProductstate — the caller (single-item flow or batch loop) decides what to dowith the resolved id, instead of the dialog's button
onClickbranching on which flow is active.ShoppingListPage.openQuantityDialogchanges fromvoidtoPromise<boolean>(resolvestrueon Save/Remove,
falseon Cancel/Escape/backdrop). Existing fire-and-forget callers(
SmartAddInput's single-item paths,ShoppingProductItem's manual activate) are unaffected —they just don't await it.
Resolution order matters for correctness:
ShoppingProductQuantityDialog'sconfirm()callsonChanged(→ resolvestrue) beforeonClose(→ would resolvefalse); a promise only honorsits first
resolve()call, so confirming always wins over the subsequent close.Abort semantics
A dismissed picker/popup (
nullcategory, or quantity popup resolvingfalse) sets a localabortedflag that stops the loop before the next line — matching the AC's "Escape bricht diegesamte restliche Verarbeitung ab, vorherige Zeilen bleiben erhalten". The line already in flight
when the dismissal happens keeps whatever
AddOrActivateShoppingProductCommandalreadycreated/activated for it (same as the pre-existing single-item dismiss behavior: leaves the product
on the list, just without a category/quantity) — nothing is rolled back, only the remaining
lines are dropped.
Any non-network
catchinside a single line (a failed create/move call) skips that line andcontinues rather than aborting the whole batch —
callApialready surfaces its own error toast, sothis only prevents an unhandled rejection from stopping the other lines.
Progress display
A
{current, total}state inSmartAddInput, rendered as a small "Line {current} of {total}" textrow above the input while a batch is running. No separate Cancel affordance — Escape on the open
picker/popup is the abort path called out by the AC; adding a second cancel mechanism isn't asked
for.
Sort order during the batch
createInCategory/assignPendingCategory's existingsortOrdercomputation(
products.filter(...).length) reads theproductsprop, which is fine for a single item butwould go stale mid-batch:
productsonly updates via a parent re-render, one render behind thestill-running loop. The batch loop instead tracks its own running copy of the list, updated after
every upsert, and passes that into the category-assignment call instead of the (possibly stale)
prop.