#108 — Mehrzeiliges Einfügen für die Einkaufsliste (wie bei Todo-Listen) #111

Closed
opened 2026-08-18 13:14:09 +02:00 by lena · 1 comment
lena commented 2026-08-18 13:14:09 +02:00 (Migrated from git.butzei.de)

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 jede
einzelne Zeile eine Nachfrage auslösen und der Vorteil des Mehrzeilen-Einfügens ginge verloren).

Hintergrund: TodoInput.tsxs Mehrzeilen-Paste (splitNonBlankLines + MultilinePasteConfirmDialog) ist
strukturell 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 eindeutig
automatisch geht.

Acceptance criteria:

  • Fügt der Nutzer mehrzeiligen Text in das Hinzufügen-Feld der Einkaufsliste ein, wird analog zu
    Todo-Listen zunächst eine Bestätigung gezeigt ("N Produkte anlegen?"), mit derselben
    Zeilen-Aufteilungslogik (splitNonBlankLines: leere Zeilen ignoriert, getrimmt).
  • Nach Bestätigung werden die Zeilen sequenziell verarbeitet, jede über dieselbe Auflösung wie ein
    einzelnes Smart-Add (siehe #103): Menge und Kategorie werden zuerst automatisch versucht; nur wenn eine
    Zeile 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.
  • Drücken von Escape während einer solchen Nachfrage bricht nicht nur die aktuelle Zeile ab, sondern die
    gesamte restliche Verarbeitung — bereits erfolgreich angelegte Produkte aus vorherigen Zeilen bleiben
    erhalten, alle noch ausstehenden Zeilen werden verworfen.
  • Ein sichtbarer Fortschritt ("Zeile 3 von 10") während der sequenziellen Verarbeitung, damit klar ist,
    dass noch etwas läuft und Escape etwas Sichtbares abbricht.

Out of scope for this story:

  • Paralleles/Batch-Anlegen mehrerer Zeilen gleichzeitig — bewusst sequenziell, damit Escape einen klaren
    Abbruchpunkt hat.
  • Verhalten bei bereits mehrfach vorkommenden identischen Zeilen im selben Einfüge-Vorgang (jede Zeile wird
    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).
# 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 jede einzelne Zeile eine Nachfrage auslösen und der Vorteil des Mehrzeilen-Einfügens ginge verloren). **Hintergrund:** `TodoInput.tsx`s Mehrzeilen-Paste (`splitNonBlankLines` + `MultilinePasteConfirmDialog`) ist strukturell 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 eindeutig automatisch geht. **Acceptance criteria:** - [ ] Fügt der Nutzer mehrzeiligen Text in das Hinzufügen-Feld der Einkaufsliste ein, wird analog zu Todo-Listen zunächst eine Bestätigung gezeigt ("N Produkte anlegen?"), mit derselben Zeilen-Aufteilungslogik (`splitNonBlankLines`: leere Zeilen ignoriert, getrimmt). - [ ] Nach Bestätigung werden die Zeilen **sequenziell** verarbeitet, jede über dieselbe Auflösung wie ein einzelnes Smart-Add (siehe `#103`): Menge und Kategorie werden zuerst automatisch versucht; nur wenn eine Zeile 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. - [ ] Drücken von Escape während einer solchen Nachfrage bricht nicht nur die aktuelle Zeile ab, sondern die gesamte restliche Verarbeitung — bereits erfolgreich angelegte Produkte aus vorherigen Zeilen bleiben erhalten, alle noch ausstehenden Zeilen werden verworfen. - [ ] Ein sichtbarer Fortschritt ("Zeile 3 von 10") während der sequenziellen Verarbeitung, damit klar ist, dass noch etwas läuft und Escape etwas Sichtbares abbricht. **Out of scope for this story:** - Paralleles/Batch-Anlegen mehrerer Zeilen gleichzeitig — bewusst sequenziell, damit Escape einen klaren Abbruchpunkt hat. - Verhalten bei bereits mehrfach vorkommenden identischen Zeilen im selben Einfüge-Vorgang (jede Zeile wird 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).
lena commented 2026-08-18 13:14:10 +02:00 (Migrated from git.butzei.de)

design (108_shopping_multiline_paste_design.md)

Design note — #108 Mehrzeiliges Einfügen für die Einkaufsliste

No backend change. AddOrActivateShoppingProductCommand (#103) already does everything a line
needs 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 existing
per-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's handlePaste (splitNonBlankLines, only intercepted when the
field 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 via
e.clipboardData.getData('text') in the paste event, before any native collapsing happens.

Reused MultilinePasteConfirmDialog gets a new optional noun prop (default 'todo') so
SmartAddInput can render noun="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 to
preserve (a single-line <input> never auto-submitted on paste before this story), so there's
nothing to fall back to.

Sequential per-line resolution

Confirming runs a for loop over the split lines, awaiting one line fully before starting the
next (required for the picker/popup to target the right line, and per the AC's explicit
"sequential, not parallel"). Each line:

  1. Calls AddOrActivateShoppingProductCommand (the same call smartFallbackAdd already makes for
    a single item) — this alone resolves both quantity and category for most real input.
  2. If categoryId came back null, opens the category picker and awaits a choice.
  3. If quantity is still falsy, opens the quantity popup (owned by ShoppingListPage, shared with
    the rest of the page) and awaits it closing.

The local-embedding "Did you mean" product-match / category-match chips (#82) are not part of
this 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 per
line 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 by
ShoppingListPage, shared with ShoppingProductItem's manual-activate click) were fire-and-forget
before 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 old
    pendingCategoryProduct state — the caller (single-item flow or batch loop) decides what to do
    with the resolved id, instead of the dialog's button onClick branching on which flow is active.
  • ShoppingListPage.openQuantityDialog changes from void to Promise<boolean> (resolves true
    on Save/Remove, false on 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's confirm() calls
onChanged (→ resolves true) before onClose (→ would resolve false); a promise only honors
its first resolve() call, so confirming always wins over the subsequent close.

Abort semantics

A dismissed picker/popup (null category, or quantity popup resolving false) sets a local
aborted flag that stops the loop before the next line — matching the AC's "Escape bricht die
gesamte restliche Verarbeitung ab, vorherige Zeilen bleiben erhalten". The line already in flight
when the dismissal happens keeps whatever AddOrActivateShoppingProductCommand already
created/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 catch inside a single line (a failed create/move call) skips that line and
continues rather than aborting the whole batch — callApi already surfaces its own error toast, so
this only prevents an unhandled rejection from stopping the other lines.

Progress display

A {current, total} state in SmartAddInput, rendered as a small "Line {current} of {total}" text
row 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 existing sortOrder computation
(products.filter(...).length) reads the products prop, which is fine for a single item but
would go stale mid-batch: products only updates via a parent re-render, one render behind the
still-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.

**design** (`108_shopping_multiline_paste_design.md`) # Design note — `#108` Mehrzeiliges Einfügen für die Einkaufsliste **No backend change.** `AddOrActivateShoppingProductCommand` (`#103`) already does everything a line needs 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 existing per-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`'s `handlePaste` (`splitNonBlankLines`, only intercepted when the field 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 via `e.clipboardData.getData('text')` in the `paste` event, before any native collapsing happens. Reused `MultilinePasteConfirmDialog` gets a new optional `noun` prop (default `'todo'`) so `SmartAddInput` can render `noun="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 to preserve (a single-line `<input>` never auto-submitted on paste before this story), so there's nothing to fall back to. ## Sequential per-line resolution Confirming runs a `for` loop over the split lines, awaiting one line fully before starting the next (required for the picker/popup to target the right line, and per the AC's explicit "sequential, not parallel"). Each line: 1. Calls `AddOrActivateShoppingProductCommand` (the same call `smartFallbackAdd` already makes for a single item) — this alone resolves both quantity and category for most real input. 2. If `categoryId` came back `null`, opens the category picker and awaits a choice. 3. If `quantity` is still falsy, opens the quantity popup (owned by `ShoppingListPage`, shared with the rest of the page) and awaits it closing. The local-embedding "Did you mean" product-match / category-match chips (`#82`) are **not** part of this 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 per line 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 by `ShoppingListPage`, shared with `ShoppingProductItem`'s manual-activate click) were fire-and-forget before 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 old `pendingCategoryProduct` state — the caller (single-item flow or batch loop) decides what to do with the resolved id, instead of the dialog's button `onClick` branching on which flow is active. - `ShoppingListPage.openQuantityDialog` changes from `void` to `Promise<boolean>` (resolves `true` on Save/Remove, `false` on 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`'s `confirm()` calls `onChanged` (→ resolves `true`) *before* `onClose` (→ would resolve `false`); a promise only honors its first `resolve()` call, so confirming always wins over the subsequent close. ## Abort semantics A dismissed picker/popup (`null` category, or quantity popup resolving `false`) sets a local `aborted` flag that stops the loop before the next line — matching the AC's "Escape bricht die gesamte restliche Verarbeitung ab, vorherige Zeilen bleiben erhalten". The line already in flight when the dismissal happens keeps whatever `AddOrActivateShoppingProductCommand` already created/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 `catch` inside a single line (a failed create/move call) skips that line and continues rather than aborting the whole batch — `callApi` already surfaces its own error toast, so this only prevents an unhandled rejection from stopping the other lines. ## Progress display A `{current, total}` state in `SmartAddInput`, rendered as a small "Line {current} of {total}" text row 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 existing `sortOrder` computation (`products.filter(...).length`) reads the `products` prop, which is fine for a single item but would go stale mid-batch: `products` only updates via a parent re-render, one render behind the still-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.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#111
No description provided.