Einkaufsliste bekommt ein Gedaechtnis - und den Knopf aus dem Rezept
Die Einkaufsliste war vollstaendig GERECHNET: Artikel unter ihrem
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt, es
ist die Staerke der Liste. Was fehlte, war ein Ort fuer "Milch gehoert
drauf, weil ich das sage" - und damit auch fuer "diese Zutaten fehlen mir
fuer das Rezept". Der Knopf konnte nirgends hinschreiben.
Zwei Tabellen dafuer, und die Trennung ist der Kern:
shopping_entries haelt, was jemand bewusst draufgesetzt hat. Genau eines
von Artikel, Gruppe oder Freitext - der Freitext ist unverzichtbar, denn
eine Rezeptzutat ohne Artikel ("Ras el Hanout") kommt nur so auf den
Zettel, und gerade die unbekannte Zutat ist die, die man vergisst.
shopping_checks haelt nur den HAKEN an einem gerechneten Posten, nicht
den Posten. Die gerechneten Vorschlaege duerfen keine Zeile bekommen: Sie
aendern sich mit jedem Einlagern, eine materialisierte Kopie waere sofort
falsch. Abhaken ging deshalb bisher nur im Browser und war nach dem
Neuladen weg - mitten im Supermarkt.
Aus einem Rezept uebernommen wird die FEHLMENGE, nicht die Rezeptmenge:
Wer 250 g braucht und 100 g hat, kauft 150 g. Gerechnet wird frisch mit
der Portionszahl, die auf dem Schirm steht. Je Rezept und Ziel gibt es
hoechstens einen offenen Eintrag - ein zweiter Druck nach dem Hochdrehen
der Portionen rechnet die Menge neu, statt die Liste zu verdoppeln. Ueber
verschiedene Rezepte hinweg bleiben es getrennte Zeilen, denn derselbe
Artikel kann zweimal fehlen. Ein abgehakter Eintrag blockiert nichts -
abgehakt heisst gekauft.
Mengen formatiert dieselbe Funktion wie beim Rezeptabgleich, damit die
Liste eine Sprache spricht: "2 Packungen" statt "1500 ml", aufgerundet,
weil man keine halbe Packung kauft.
In der Oberfläche eine eigene Karte "Selbst draufgesetzt" mit Eingabefeld,
Herkunftsangabe ("fuer Pfannkuchen") und "Erledigte entfernen", das beide
Quellen zusammen aufraeumt - fuer den Nutzer ist es eine Liste. Das
Abhaken wandert sofort und speichert im Hintergrund; schlaegt es fehl,
springt der Haken zurueck. Die Seite holt jetzt alles mit einem Aufruf
statt mit drei.
Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen fuer 48
Portionen ergibt 2 Packungen Mehl, 2 Packungen Milch, 3 Packungen Eier
und "Ras el Hanout" als Notiz; derselbe Knopf bei 96 Portionen
aktualisiert diese vier statt acht daraus zu machen; ein Haken
ueberlebt das Neuladen. 17 neue Tests, Suite bei 512.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f02e9a3840
commit
795e233a1b
@@ -3,6 +3,7 @@ import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
import ZuordnungDialog from "../components/ZuordnungDialog";
|
||||
import { useToast } from "../toast";
|
||||
import { fmt } from "../units";
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,8 @@ export default function Rezepte() {
|
||||
// Antworten in falscher Reihenfolge eintreffen - nur die neueste zaehlt.
|
||||
const anfrage = useRef(0);
|
||||
const tippTimer = useRef(null);
|
||||
const [uebernimmt, setUebernimmt] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
async function ladeListe(q = "") {
|
||||
setLaedt(true);
|
||||
@@ -114,6 +117,32 @@ export default function Rezepte() {
|
||||
const fehlende = (rezept?.ingredients || []).filter(
|
||||
(z) => z.status === "fehlt" || z.status === "zu_wenig",
|
||||
);
|
||||
// Auch die nicht zugeordneten Zutaten gehoeren auf den Zettel - gerade die
|
||||
// unbekannte Zutat ist die, die man vergisst.
|
||||
const offene = (rezept?.ingredients || []).filter(
|
||||
(z) => z.status === "fehlt" || z.status === "zu_wenig" || z.status === "kein_treffer",
|
||||
);
|
||||
|
||||
async function aufEinkaufsliste() {
|
||||
if (!slug || uebernimmt) return;
|
||||
setUebernimmt(true);
|
||||
try {
|
||||
const zahl = Number(String(portionen).replace(",", "."));
|
||||
const antwort = await api.rezeptAufEinkaufsliste(
|
||||
slug, Number.isFinite(zahl) && zahl > 0 ? zahl : undefined,
|
||||
);
|
||||
const n = antwort.entries.length;
|
||||
const teile = [`${n} Posten auf der Einkaufsliste`];
|
||||
if (antwort.aktualisiert) teile.push(`${antwort.aktualisiert} davon neu gerechnet`);
|
||||
if (antwort.freitext) teile.push(`${antwort.freitext} als Notiz ohne Artikel`);
|
||||
toast(n === 0 ? "Es fehlt nichts – nichts hinzugefügt." : `${teile.join(", ")}.`);
|
||||
setFehler(null);
|
||||
} catch (err) {
|
||||
setFehler(err.message);
|
||||
} finally {
|
||||
setUebernimmt(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -260,11 +289,19 @@ export default function Rezepte() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!rezept.kochbar && (
|
||||
<p className="muted small">
|
||||
Was fehlt, steht oben rot. Der Knopf „Fehlende zur Einkaufsliste"
|
||||
kommt, sobald die Einkaufsliste eigene Einträge speichern kann.
|
||||
</p>
|
||||
{offene.length > 0 && (
|
||||
<div className="field-inline" style={{ flexWrap: "wrap", marginBottom: 0 }}>
|
||||
<button type="button" className="btn primary"
|
||||
disabled={rechnet || uebernimmt} onClick={aufEinkaufsliste}>
|
||||
<Icon name="cart" size={16} />
|
||||
Fehlende zur Einkaufsliste
|
||||
</button>
|
||||
<span className="muted small">
|
||||
{offene.length === 1 ? "1 Posten" : `${offene.length} Posten`} – mit der
|
||||
Fehlmenge für {fmt(Number(String(portionen).replace(",", ".")) || rezept.base_servings || 1)} Portionen.
|
||||
Nicht zugeordnete Zutaten kommen als Notiz drauf.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
+139
-10
@@ -2,25 +2,97 @@ import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import ShoppingNeedText from "../components/ShoppingNeedText";
|
||||
import { useToast } from "../toast";
|
||||
import { anzahlWort } from "../units";
|
||||
|
||||
/**
|
||||
* Einkaufsliste aus zwei Quellen.
|
||||
*
|
||||
* **Gerechnet**: Artikel und Gruppen unter ihrem Mindestbestand, Bedarfe je
|
||||
* Lagerort. Diese Posten haben keine Zeile in der Datenbank — sie entstehen bei
|
||||
* jedem Aufruf neu. Gemerkt wird nur der Haken daran, sonst wäre er nach dem
|
||||
* Neuladen weg, mitten im Supermarkt.
|
||||
*
|
||||
* **Gespeichert**: was jemand selbst draufgesetzt hat, und die fehlenden
|
||||
* Zutaten eines Rezepts. Die bleiben liegen, bis sie abgehakt sind.
|
||||
*/
|
||||
export default function ShoppingList() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [byLocation, setByLocation] = useState([]);
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [checked, setChecked] = useState({});
|
||||
const [neu, setNeu] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation()])
|
||||
.then(([p, g, l]) => { setItems(p); setGroups(g); setByLocation(l); })
|
||||
.catch((e) => setError(e.message));
|
||||
}, []);
|
||||
async function laden() {
|
||||
try {
|
||||
const alles = await api.shoppingAll();
|
||||
setItems(alles.products || []);
|
||||
setGroups(alles.groups || []);
|
||||
setByLocation(alles.by_location || []);
|
||||
setEntries(alles.entries || []);
|
||||
setChecked(Object.fromEntries((alles.checked || []).map((k) => [k, true])));
|
||||
setError(null);
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
useEffect(() => { laden(); }, []);
|
||||
|
||||
// Bezieht sich nur auf die GERECHNETEN Posten: Die eigenen Eintraege stehen in
|
||||
// ihrer eigenen Karte. Zaehlte man sie hier mit, zeigte die untere Karte eine
|
||||
// leere Liste statt "alles erreicht".
|
||||
const empty = items.length === 0 && groups.length === 0;
|
||||
const erledigt = entries.filter((e) => e.done).length
|
||||
+ Object.values(checked).filter(Boolean).length;
|
||||
|
||||
function toggle(key, val) {
|
||||
// Der Haken wandert sofort und wird im Hintergrund gespeichert: Im Laden soll
|
||||
// niemand auf eine Antwort warten. Schlaegt es fehl, springt er zurueck.
|
||||
async function toggle(key, val) {
|
||||
setChecked((c) => ({ ...c, [key]: val }));
|
||||
try {
|
||||
if (val) await api.setShoppingCheck(key);
|
||||
else await api.clearShoppingCheck(key);
|
||||
} catch (e) {
|
||||
setChecked((c) => ({ ...c, [key]: !val }));
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function eintragAbhaken(eintrag, val) {
|
||||
setEntries((es) => es.map((e) => (e.id === eintrag.id ? { ...e, done: val } : e)));
|
||||
try {
|
||||
await api.updateShoppingEntry(eintrag.id, { done: val });
|
||||
} catch (e) {
|
||||
setEntries((es) => es.map((x) => (x.id === eintrag.id ? { ...x, done: !val } : x)));
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function eintragLoeschen(eintrag) {
|
||||
try {
|
||||
await api.deleteShoppingEntry(eintrag.id);
|
||||
setEntries((es) => es.filter((e) => e.id !== eintrag.id));
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
async function hinzufuegen(ev) {
|
||||
ev.preventDefault();
|
||||
const text = neu.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
const eintrag = await api.addShoppingEntry({ free_text: text });
|
||||
setEntries((es) => [...es, eintrag]);
|
||||
setNeu("");
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
async function erledigteEntfernen() {
|
||||
try {
|
||||
const n = await api.clearDoneShopping();
|
||||
await laden();
|
||||
toast(n > 0 ? `${n} erledigte Posten entfernt.` : "Nichts abgehakt.");
|
||||
} catch (e) { setError(e.message); }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -28,7 +100,9 @@ export default function ShoppingList() {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Einkaufsliste</h1>
|
||||
<div className="sub">Produkte und Gruppen unter ihrem Mindestbestand</div>
|
||||
<div className="sub">
|
||||
Eigene Einträge und was unter dem Mindestbestand liegt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
@@ -37,6 +111,54 @@ export default function ShoppingList() {
|
||||
<div className="sub" style={{ marginBottom: "var(--sp-2)" }}>Überall (egal wo)</div>
|
||||
)}
|
||||
|
||||
{/* Dieser Abschnitt steht auch leer da - er ist das Eingabefeld fuer
|
||||
eigene Eintraege und muss deshalb auffindbar sein. */}
|
||||
<div className="card" style={{ marginBottom: "var(--sp-4)" }}>
|
||||
<div className="card-head"><Icon name="cart" /><h2>Selbst draufgesetzt</h2></div>
|
||||
<form className="field-inline" onSubmit={hinzufuegen}>
|
||||
<input value={neu} placeholder="z.B. Backpapier"
|
||||
onChange={(e) => setNeu(e.target.value)} />
|
||||
<button className="btn" type="submit" disabled={!neu.trim()}>
|
||||
<Icon name="plus" size={16} />Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
{entries.length === 0 ? (
|
||||
<p className="muted small mt-0">
|
||||
Hier stehen eigene Einträge und die fehlenden Zutaten, die du aus
|
||||
einem Rezept übernimmst. Anders als die gerechneten Posten unten
|
||||
bleiben sie liegen, bis du sie abhakst.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="checklist">
|
||||
{entries.map((e) => (
|
||||
<li key={e.id} className={e.done ? "done" : ""}>
|
||||
<label>
|
||||
<input type="checkbox" checked={e.done}
|
||||
onChange={(ev) => eintragAbhaken(e, ev.target.checked)} />
|
||||
{e.art === "group" && <span className="badge accent">Gruppe</span>}
|
||||
{e.art === "text" && (
|
||||
<span className="badge" title="Zu diesem Eintrag gibt es hier keinen Artikel.">
|
||||
Notiz
|
||||
</span>
|
||||
)}
|
||||
<span className="item-name">{e.name}</span>
|
||||
{e.recipe_name && (
|
||||
<span className="muted small">für {e.recipe_name}</span>
|
||||
)}
|
||||
</label>
|
||||
<span className="muted small" style={{ display: "flex", gap: "var(--sp-2)", alignItems: "center" }}>
|
||||
{e.menge_text}
|
||||
<button type="button" className="btn sm ghost" title="Eintrag entfernen"
|
||||
onClick={() => eintragLoeschen(e)}>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{empty ? (
|
||||
<div className="empty">Alle Mindestbestände erreicht.</div>
|
||||
@@ -126,9 +248,16 @@ export default function ShoppingList() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="muted small">
|
||||
Das Abhaken hilft beim Einkaufen und wird (noch) nicht dauerhaft gespeichert.
|
||||
</p>
|
||||
<div className="field-inline" style={{ marginTop: "var(--sp-4)", flexWrap: "wrap" }}>
|
||||
<button type="button" className="btn" disabled={erledigt === 0}
|
||||
onClick={erledigteEntfernen}>
|
||||
<Icon name="trash" size={16} />Erledigte entfernen
|
||||
{erledigt > 0 ? ` (${erledigt})` : ""}
|
||||
</button>
|
||||
<span className="muted small">
|
||||
Das Abhaken wird gespeichert – ein Neuladen im Laden verliert nichts.
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted small">
|
||||
Bedarfe sind gegeneinander verrechnet: Was du für einen Lagerort oder eine
|
||||
Untergruppe kaufst, deckt „Überall“ bzw. die Obergruppe mit ab. Hängt
|
||||
|
||||
Reference in New Issue
Block a user