Rezeptseite: Portionen drehen laesst die Seite stehen

Bisher setzte jede Aenderung an den Portionen das Rezept auf null - die
ganze rechte Karte verschwand und zeigte "Links ein Rezept waehlen",
bevor sie neu aufbaute. Bei einem Druck auf den Zahlenpfeil ist das ein
Flackern, bei getippten Zahlen dreimal hintereinander.

Jetzt bleibt stehen, was sich nicht aendert: Rezeptname, Portionenfeld
und die Zutatenliste. Nur die drei Wertespalten (Gebraucht, Im Haus,
Zustand) sind waehrend des Rechnens unklar und zeigen einen schimmernden
Platzhalter; das Ergebnis blendet sich danach ein (.wert-ein, 0.28s, mit
Ruecksicht auf prefers-reduced-motion). Das Kopf-Abzeichen sagt in der
Zeit "wird gerechnet..." statt eine veraltete Zahl, und die roten/gelben
Zeilenfarben setzen aus - sie gelten fuer die alten Mengen.

Dazu zwei Dinge, die ohne das Standenbleiben nicht aufgefallen waeren:

- Das Zahlenfeld reagiert sofort, die Anfrage wartet 280 ms. Wer von 4
  auf 12 tippt, bekam sonst erst ein Ergebnis fuer 1.
- Antworten koennen in falscher Reihenfolge eintreffen. Eine laufende
  Nummer je Anfrage verwirft alles, was nicht die neueste ist - sonst
  gewinnt beim schnellen Klicken zufaellig die langsamere Antwort.

Ausserdem: Eine Knopfreihe direkt unter einer Angabenliste klebte am
letzten Wert (Mealie-Karte in den Einstellungen). .kv hat bewusst keinen
Aussenabstand und .field-inline nur einen nach unten - den fehlenden
setzt jetzt eine Geschwisterregel, damit es ueberall gilt, wo dieses
Paar vorkommt.

Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen von 4 auf 12
Portionen - waehrend des Ladens 15 Platzhalter und die Zutatennamen
stehen, danach 250 g -> 750 g und 2 EL -> 6 EL (60 g), keine leere Karte
dazwischen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 14:20:33 +02:00
co-authored by Claude Opus 5
parent 53a8b9e0b6
commit f02e9a3840
2 changed files with 98 additions and 22 deletions
+69 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { Skeleton } from "../components/Skeleton";
import ZuordnungDialog from "../components/ZuordnungDialog"; import ZuordnungDialog from "../components/ZuordnungDialog";
import { fmt } from "../units"; import { fmt } from "../units";
@@ -41,6 +42,17 @@ export default function Rezepte() {
const [laedt, setLaedt] = useState(true); const [laedt, setLaedt] = useState(true);
// Welche Zutat gerade von Hand zugeordnet wird (null = keine). // Welche Zutat gerade von Hand zugeordnet wird (null = keine).
const [zuordnen, setZuordnen] = useState(null); const [zuordnen, setZuordnen] = useState(null);
// Beim Drehen an den Portionen bleibt das Rezept stehen und nur die Werte
// werden neu gerechnet. Die Zutatenliste selbst aendert sich dabei nie -
// sie darf deshalb auch nicht verschwinden.
const [rechnet, setRechnet] = useState(false);
// Zaehlt die fertigen Ladevorgaenge und dient als React-Schluessel, damit die
// Einblend-Animation bei jedem neuen Ergebnis erneut anlaeuft.
const [stand, setStand] = useState(0);
// Laufende Nummer der Anfrage: Beim schnellen Klicken am Zahlenfeld koennen
// Antworten in falscher Reihenfolge eintreffen - nur die neueste zaehlt.
const anfrage = useRef(0);
const tippTimer = useRef(null);
async function ladeListe(q = "") { async function ladeListe(q = "") {
setLaedt(true); setLaedt(true);
@@ -52,30 +64,53 @@ export default function Rezepte() {
} }
useEffect(() => { ladeListe(); }, []); useEffect(() => { ladeListe(); }, []);
async function oeffne(s, wunschPortionen) { /**
* Rezept laden. ``behalten`` heisst: Es ist dasselbe Rezept, nur mit anderen
* Portionen - dann bleibt alles stehen und nur die Werte gelten als unklar.
* Ohne das flackerte die ganze Seite bei jedem Druck auf den Zahlenpfeil.
*/
async function oeffne(s, wunschPortionen, { behalten = false } = {}) {
setSlug(s); setSlug(s);
setRezept(null); if (behalten) setRechnet(true);
else setRezept(null);
const lauf = ++anfrage.current;
try { try {
const daten = await api.recipe(s, wunschPortionen); const daten = await api.recipe(s, wunschPortionen);
if (lauf !== anfrage.current) return; // eine neuere Anfrage laeuft
setRezept(daten); setRezept(daten);
setStand((n) => n + 1);
// Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen. // Beim ersten Öffnen das Feld mit der Rezept-Portionszahl vorbelegen.
if (wunschPortionen == null) setPortionen(daten.servings ? String(daten.servings) : ""); if (wunschPortionen == null) setPortionen(daten.servings ? String(daten.servings) : "");
setFehler(null); setFehler(null);
} catch (err) { setFehler(err.message); } } catch (err) {
if (lauf === anfrage.current) setFehler(err.message);
} finally {
if (lauf === anfrage.current) setRechnet(false);
}
} }
// Nach einer Zuordnung muss der Abgleich neu laufen: Die Zutat hat jetzt ein // Nach einer Zuordnung muss der Abgleich neu laufen: Die Zutat hat jetzt ein
// Ziel, einen Bestand und einen Zustand. // Ziel, einen Bestand und einen Zustand.
async function neuRechnen() { async function neuRechnen() {
if (slug) await oeffne(slug, Number(String(portionen).replace(",", ".")) || undefined); if (slug) {
await oeffne(slug, Number(String(portionen).replace(",", ".")) || undefined,
{ behalten: true });
}
} }
// Das Zahlenfeld reagiert sofort, die Anfrage wartet kurz: Wer von 4 auf 12
// tippt, soll nicht erst ein Ergebnis fuer 1 bekommen.
function setzePortionen(wert) { function setzePortionen(wert) {
setPortionen(wert); setPortionen(wert);
const zahl = Number(String(wert).replace(",", ".")); const zahl = Number(String(wert).replace(",", "."));
if (slug && Number.isFinite(zahl) && zahl > 0) oeffne(slug, zahl); if (tippTimer.current) clearTimeout(tippTimer.current);
if (!slug || !Number.isFinite(zahl) || zahl <= 0) return;
setRechnet(true);
tippTimer.current = setTimeout(() => oeffne(slug, zahl, { behalten: true }), 280);
} }
useEffect(() => () => { if (tippTimer.current) clearTimeout(tippTimer.current); }, []);
const fehlende = (rezept?.ingredients || []).filter( const fehlende = (rezept?.ingredients || []).filter(
(z) => z.status === "fehlt" || z.status === "zu_wenig", (z) => z.status === "fehlt" || z.status === "zu_wenig",
); );
@@ -129,10 +164,16 @@ export default function Rezepte() {
<div className="card-head"> <div className="card-head">
<Icon name={rezept.kochbar ? "check" : "cart"} /> <Icon name={rezept.kochbar ? "check" : "cart"} />
<h2>{rezept.name}</h2> <h2>{rezept.name}</h2>
<span className={`badge ${rezept.kochbar ? "ok" : "warn"}`} {rechnet ? (
<span className="badge nowrap" style={{ marginLeft: "auto" }}>
wird gerechnet…
</span>
) : (
<span key={stand} className={`badge wert-ein ${rezept.kochbar ? "ok" : "warn"}`}
style={{ marginLeft: "auto" }}> style={{ marginLeft: "auto" }}>
{rezept.kochbar ? "kochbar" : `${fehlende.length} fehlt`} {rezept.kochbar ? "kochbar" : `${fehlende.length} fehlt`}
</span> </span>
)}
</div> </div>
<div className="field-inline" style={{ flexWrap: "wrap" }}> <div className="field-inline" style={{ flexWrap: "wrap" }}>
@@ -158,8 +199,8 @@ export default function Rezepte() {
</thead> </thead>
<tbody> <tbody>
{rezept.ingredients.map((z, i) => ( {rezept.ingredients.map((z, i) => (
<tr key={i} className={ <tr key={i} className={rechnet ? ""
z.status === "fehlt" ? "row-danger" : z.status === "fehlt" ? "row-danger"
: z.status === "zu_wenig" ? "row-warn" : ""}> : z.status === "zu_wenig" ? "row-warn" : ""}>
<td data-label="Zutat"> <td data-label="Zutat">
<span className="strong">{z.text}</span> <span className="strong">{z.text}</span>
@@ -179,22 +220,40 @@ export default function Rezepte() {
</button> </button>
)} )}
</td> </td>
{/* Nur die WERTE sind beim Umrechnen unklar, die Zutat
selbst nicht. Deshalb bleibt die Zeile stehen und nur
diese drei Zellen zeigen einen Platzhalter; das
Ergebnis blendet sich danach ein. */}
<td data-label="Gebraucht"> <td data-label="Gebraucht">
{rechnet ? <Skeleton w="70%" /> : (
<span key={stand} className="wert-ein">
{z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"} {z.menge != null ? `${fmt(z.menge)} ${z.einheit}`.trim() : "–"}
{z.benoetigt_base != null && ( {z.benoetigt_base != null && (
<div className="muted small"> <div className="muted small">
{fmt(z.benoetigt_base)} {kurz(z.basis_einheit)} {fmt(z.benoetigt_base)} {kurz(z.basis_einheit)}
</div> </div>
)} )}
</span>
)}
</td> </td>
<td data-label="Im Haus"> <td data-label="Im Haus">
{rechnet ? <Skeleton w="55%" /> : (
<span key={stand} className="wert-ein">
{z.bestand_base != null {z.bestand_base != null
? `${fmt(z.bestand_base)} ${kurz(z.basis_einheit)}`.trim() : "–"} ? `${fmt(z.bestand_base)} ${kurz(z.basis_einheit)}`.trim() : "–"}
{z.fehlt_text && ( {z.fehlt_text && (
<div className="muted small">fehlt: {z.fehlt_text}</div> <div className="muted small">fehlt: {z.fehlt_text}</div>
)} )}
</span>
)}
</td>
<td data-label="Zustand">
{rechnet ? <Skeleton w={84} /> : (
<span key={stand} className="wert-ein">
<Zustand status={z.status} />
</span>
)}
</td> </td>
<td data-label="Zustand"><Zustand status={z.status} /></td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+17
View File
@@ -980,6 +980,19 @@ select.zeitraum:hover { color: var(--text); }
animation: skeleton-glanz 1.4s ease infinite; animation: skeleton-glanz 1.4s ease infinite;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
/* Neu gerechnete Werte blenden sich ein, statt hart umzuspringen. Der
React-Schluessel (stand) laesst die Animation bei jedem Ergebnis neu
anlaufen. */
.wert-ein { animation: wert-ein 0.28s ease both; }
@keyframes wert-ein {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: none; }
}
/* Wer Animationen abbestellt hat, bekommt den Wert sofort. */
@media (prefers-reduced-motion: reduce) {
.wert-ein { animation: none; }
.skeleton { animation: none; }
}
@keyframes skeleton-glanz { @keyframes skeleton-glanz {
from { background-position: 100% 0; } from { background-position: 100% 0; }
to { background-position: 0 0; } to { background-position: 0 0; }
@@ -1020,6 +1033,10 @@ select.zeitraum:hover { color: var(--text); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-5); margin: 0; } .kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-5); margin: 0; }
.kv dt { color: var(--muted); font-size: 0.85rem; } .kv dt { color: var(--muted); font-size: 0.85rem; }
.kv dd { margin: 0; } .kv dd { margin: 0; }
/* Eine Knopfreihe direkt unter einer Angabenliste klebte am letzten Wert -
.kv hat bewusst keinen Aussenabstand, .field-inline nur einen nach unten.
Den fehlenden Abstand setzt deshalb die Geschwisterregel. */
.kv + .field-inline { margin-top: var(--sp-4); }
/* ---------- Utilities ---------- */ /* ---------- Utilities ---------- */
.muted { color: var(--muted); } .muted { color: var(--muted); }