Zutaten von Hand zuordnen - und Vorrania merkt es sich

Der unscharfe Abgleich muss raten und wird das immer tun: "Lime Curd" hat
mit "Zitronencreme" kein Wort gemeinsam, und manchmal liegt er daneben.
Bisher war das eine Sackgasse - die Zutat blieb unzugeordnet, bei jedem
Aufruf neu.

Jetzt traegt jede Zutatenzeile einen Knopf "Zuordnen". Entschieden wird
gegen Artikel UND Gruppen, gemerkt unter dem normalisierten Namen des
Lebensmittels: "Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " treffen
dieselbe Zeile. Ab dann gilt die Entscheidung fuer jedes Rezept, in dem
diese Zutat vorkommt - eine Zutat wird hoechstens einmal zugeordnet.

Schluessel ist der Name, nicht die Mealie-ID: IDs sind UUIDs, die beim
Neuanlegen eines Lebensmittels wechseln, und nur ueber den Namen greift
die Zuordnung auch fuer Zutaten ohne verknuepftes Lebensmittel ("2 Eier").

Im Auswahldialog stehen oben die Vorschlaege, die der Abgleich selbst
gefunden hat - meist ist der Richtige darunter, nur nicht an erster
Stelle -, darunter die freie Suche ueber alles. Die Basiseinheit steht
dabei, damit nicht wieder Milliliter auf ein Gramm-Produkt wandern.

Beim Raten bleiben Gegenstaende aussen vor; von Hand darf alles gewaehlt
werden ausser Einzelstuecken. Backpapier ist kein Lebensmittel und steht
trotzdem in Rezepten, waehrend ein Einzelstueck nie eine Zutat ist.

Zeigt das Ziel ins Leere, weil der Artikel geloescht wurde, faellt die
Zeile beim Laden still heraus: Auf SQLite greift kein Fremdschluessel,
und ein geloeschter Artikel darf kein Rezept mehr sprengen.

Die Zuordnungs-Routen stehen bewusst vor /recipes/{slug}, sonst liest
FastAPI "zuordnungen" als Rezept-Slug und fragt Mealie danach. Ein Test
wacht ueber die Reihenfolge, weil ein Umsortieren der Datei den Fehler
sonst still einbauen wuerde.

16 neue Tests, Suite bei 487.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 13:40:23 +02:00
co-authored by Claude Opus 5
parent 2a56cca3c8
commit 8072e7f566
9 changed files with 773 additions and 3 deletions
+8
View File
@@ -459,6 +459,14 @@ export const api = {
},
recipe: (slug, servings) =>
request(`/recipes/${encodeURIComponent(slug)}${servings ? `?servings=${servings}` : ""}`),
// Zutaten von Hand zuordnen. Der Zutatenname ist der Schluessel - er reist
// als Abfrageparameter, weil Lebensmittelnamen Schraegstriche enthalten.
zuordnungen: () => request("/recipes/zuordnungen"),
zuordnungsZiele: (q) =>
request(`/recipes/zuordnungen/ziele${q ? `?q=${encodeURIComponent(q)}` : ""}`),
setzeZuordnung: (body) => request("/recipes/zuordnungen", { method: "PUT", body }),
loescheZuordnung: (food) =>
request(`/recipes/zuordnungen?food=${encodeURIComponent(food)}`, { method: "DELETE" }),
listSettings: () => request("/settings"),
setSetting: (key, value) =>
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useRef, useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
/**
* Eine Rezeptzutat von Hand einem Artikel oder einer Gruppe zuordnen.
*
* Nötig, weil der automatische Abgleich raten muss: „Lime Curd" hat mit
* „Zitronencreme" kein Wort gemeinsam, und manchmal liegt er daneben. Was hier
* entschieden wird, merkt sich der Server unter dem Zutatennamen – es gilt ab
* dann für jedes Rezept, in dem diese Zutat vorkommt.
*
* Oben die Vorschläge, die der Abgleich selbst gefunden hat (meist ist der
* Richtige darunter, nur nicht an erster Stelle), darunter die freie Suche über
* alles. Die Basiseinheit steht dabei – damit man nicht wieder Milliliter auf
* ein Gramm-Produkt legt.
*/
const BASIS_KURZ = { gram: "g", milliliter: "ml", piece: "Stk" };
export default function ZuordnungDialog({ zutat, onClose, onDone, onError }) {
const [suche, setSuche] = useState(zutat.food || "");
const [ziele, setZiele] = useState([]);
const [laedt, setLaedt] = useState(false);
const [busy, setBusy] = useState(false);
const letzte = useRef(0);
// Tippen soll nicht je Buchstabe eine Anfrage auslösen; 250 ms reichen, um
// eine Eingabe zu Ende zu schreiben.
useEffect(() => {
const id = setTimeout(async () => {
const lauf = ++letzte.current;
setLaedt(true);
try {
const daten = await api.zuordnungsZiele(suche);
if (lauf === letzte.current) setZiele(daten);
} catch (err) {
onError?.(err.message);
} finally {
if (lauf === letzte.current) setLaedt(false);
}
}, 250);
return () => clearTimeout(id);
}, [suche]);
async function waehle(art, zielId) {
if (busy) return;
setBusy(true);
try {
await api.setzeZuordnung({ food: zutat.food, art, ziel_id: zielId });
onClose();
await onDone();
} catch (err) {
onError?.(err.message);
} finally {
setBusy(false);
}
}
async function aufheben() {
if (busy) return;
setBusy(true);
try {
await api.loescheZuordnung(zutat.food);
onClose();
await onDone();
} catch (err) {
onError?.(err.message);
} finally {
setBusy(false);
}
}
const vorschlaege = (zutat.kandidaten || []).filter((k) => k.id !== zutat.ziel_id);
return (
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" role="dialog" aria-modal="true">
<div className="modal-head"><Icon name="search" size={18} /><h2>Zutat zuordnen</h2></div>
<p className="modal-body mt-0">
„{zutat.food || zutat.text}" gehört bei dir zu welchem Artikel? Die
Antwort gilt künftig für jedes Rezept mit dieser Zutat.
</p>
{zutat.ziel_name && (
<div className="modal-body" style={{ paddingTop: 0 }}>
<span className="muted small">
Derzeit: {zutat.art === "group" ? "Gruppe " : ""}{zutat.ziel_name}
{zutat.manuell ? " (von Hand)" : " (geraten)"}
</span>
</div>
)}
{vorschlaege.length > 0 && (
<div className="modal-body" style={{ paddingTop: 0 }}>
<div className="muted small" style={{ marginBottom: "var(--sp-2)" }}>
Gefundene Vorschläge
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "var(--sp-2)" }}>
{vorschlaege.map((k) => (
<button key={`${k.art}-${k.id}`} type="button" className="btn sm ghost"
disabled={busy} onClick={() => waehle(k.art, k.id)}>
{k.art === "group" ? "Gruppe " : ""}{k.name}
</button>
))}
</div>
</div>
)}
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-2)" }}>
<label style={{ margin: 0 }}>
Suchen
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input value={suche} autoFocus placeholder="Artikel oder Gruppe…"
onChange={(e) => setSuche(e.target.value)} />
</label>
{laedt && <span className="muted small">Wird gesucht…</span>}
{!laedt && ziele.length === 0 && (
<span className="muted small">Dazu findet sich nichts.</span>
)}
<ul className="clean-list" style={{ maxHeight: 260, overflowY: "auto" }}>
{ziele.map((z) => (
<li key={`${z.art}-${z.id}`}>
<button type="button" className="btn sm ghost" disabled={busy}
style={{ width: "100%", justifyContent: "flex-start" }}
onClick={() => waehle(z.art, z.id)}>
<span className="strong">{z.name}</span>
<span className="muted small" style={{ marginLeft: "auto" }}>
{z.zusatz ? `${z.zusatz} · ` : ""}
{BASIS_KURZ[z.basis_einheit] || z.basis_einheit}
</span>
</button>
</li>
))}
</ul>
</div>
<div className="btn-pair">
{zutat.manuell && (
<button type="button" className="btn" disabled={busy} onClick={aufheben}>
<Icon name="trash" size={16} />Zuordnung aufheben
</button>
)}
<button type="button" className="btn" onClick={onClose}>Abbrechen</button>
</div>
</div>
</div>
);
}
+23
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import ZuordnungDialog from "../components/ZuordnungDialog";
import { fmt } from "../units";
/**
@@ -38,6 +39,8 @@ export default function Rezepte() {
const [portionen, setPortionen] = useState("");
const [fehler, setFehler] = useState(null);
const [laedt, setLaedt] = useState(true);
// Welche Zutat gerade von Hand zugeordnet wird (null = keine).
const [zuordnen, setZuordnen] = useState(null);
async function ladeListe(q = "") {
setLaedt(true);
@@ -61,6 +64,12 @@ export default function Rezepte() {
} catch (err) { setFehler(err.message); }
}
// Nach einer Zuordnung muss der Abgleich neu laufen: Die Zutat hat jetzt ein
// Ziel, einen Bestand und einen Zustand.
async function neuRechnen() {
if (slug) await oeffne(slug, Number(String(portionen).replace(",", ".")) || undefined);
}
function setzePortionen(wert) {
setPortionen(wert);
const zahl = Number(String(wert).replace(",", "."));
@@ -157,9 +166,18 @@ export default function Rezepte() {
{z.ziel_name && (
<div className="muted small">
{z.art === "group" ? "Gruppe " : ""}{z.ziel_name}
{z.manuell && " · von Hand zugeordnet"}
</div>
)}
{z.hinweis && <div className="muted small">{z.hinweis}</div>}
{z.food && (
<button type="button" className="btn sm ghost"
style={{ marginTop: "var(--sp-1)" }}
onClick={() => setZuordnen(z)}>
<Icon name="edit" size={14} />
{z.ziel_id ? "Zuordnung ändern" : "Zuordnen"}
</button>
)}
</td>
<td data-label="Gebraucht">
{z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"}
@@ -193,6 +211,11 @@ export default function Rezepte() {
)}
</div>
</div>
{zuordnen && (
<ZuordnungDialog zutat={zuordnen} onClose={() => setZuordnen(null)}
onDone={neuRechnen} onError={setFehler} />
)}
</div>
);
}