feat: Eigene Stoffe am Lebensmittel anlegen
EPA-Chips und mg/µg vom Etikett; fehlende Parameter ohne Admin. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a5adff6196
commit
d35087ad84
|
|
@ -37,6 +37,7 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
|
||||||
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
|
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.
|
||||||
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
|
- BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git.
|
||||||
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
|
- Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen.
|
||||||
|
- Öl/Supplement mit Etikett (EPA/DHA, Vitamine) ist **ein Katalogeintrag**, keine Zutatenliste. Listen sind nur Rohmischungen aus Lebensmitteln — eine Liste kann keine andere Liste enthalten. Quarkspeise = Liste, Omega-3-Öl = Lebensmittel darin.
|
||||||
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
|
- Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar.
|
||||||
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
|
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
|
||||||
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
|
- Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import.
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
|
||||||
|
|
||||||
## Router
|
## Router
|
||||||
|
|
||||||
- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings
|
- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings; `POST /bls/attributes` legt Extension-Stoff an (Name + Einheit), `GET /bls/attributes?keys=EPA,DHA`
|
||||||
- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat
|
- Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat
|
||||||
- `/api/admin/bls/*` — Import, Katalog, Attribute
|
- `/api/admin/bls/*` — Import, Katalog, Attribute
|
||||||
- `/api/admin/food-mappings` — Admin-CRUD
|
- `/api/admin/food-mappings` — Admin-CRUD
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@ frontend/src/
|
||||||
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**.
|
- **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**.
|
||||||
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
|
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
|
||||||
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
|
- **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten.
|
||||||
- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g.
|
- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. Nutzer kann fehlende Stoffe anlegen (`POST /bls/attributes`). Öl/Supplement = ein Lebensmittel, keine Liste (Listen nicht verschachtelbar).
|
||||||
- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
|
- **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`.
|
||||||
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
|
- **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106
|
||||||
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
|
- **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
|
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
_ATTR_KEY_RE = re.compile(r"[^A-Z0-9]+")
|
||||||
|
_ALLOWED_UNITS = {"g": "g", "mg": "mg", "µg": "µg", "ug": "µg", "mcg": "µg", "kcal": "kcal", "kj": "kJ"}
|
||||||
|
|
||||||
MACRO_TO_KEY = {
|
MACRO_TO_KEY = {
|
||||||
"kcal": "ENERCC",
|
"kcal": "ENERCC",
|
||||||
"protein_g": "PROT625",
|
"protein_g": "PROT625",
|
||||||
|
|
@ -17,9 +21,24 @@ def scale_to_per_100g(value: float, serving_g: float | None) -> float:
|
||||||
return float(value) * (100.0 / float(serving_g))
|
return float(value) * (100.0 / float(serving_g))
|
||||||
|
|
||||||
|
|
||||||
def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]:
|
def list_numeric_attributes(
|
||||||
q = (query or "").strip()
|
cur, query: str = "", limit: int = 40, keys: list[str] | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
lim = min(max(int(limit or 40), 1), 80)
|
lim = min(max(int(limit or 40), 1), 80)
|
||||||
|
wanted = [str(k).strip() for k in (keys or []) if str(k).strip()]
|
||||||
|
if wanted:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
||||||
|
FROM food_attributes
|
||||||
|
WHERE is_active = true AND data_type = 'num_per_100g'
|
||||||
|
AND attr_key = ANY(%s)
|
||||||
|
ORDER BY sort_order, attr_key
|
||||||
|
""",
|
||||||
|
(wanted,),
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
q = (query or "").strip()
|
||||||
if q:
|
if q:
|
||||||
like = f"%{q}%"
|
like = f"%{q}%"
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -126,3 +145,43 @@ def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
|
||||||
(food_id, list(MACRO_TO_KEY.values())),
|
(food_id, list(MACRO_TO_KEY.values())),
|
||||||
)
|
)
|
||||||
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}
|
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_attr_key(raw: str | None) -> str:
|
||||||
|
key = _ATTR_KEY_RE.sub("_", (raw or "").strip().upper()).strip("_")
|
||||||
|
return key[:64]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_attr_unit(raw: str | None) -> str:
|
||||||
|
return _ALLOWED_UNITS.get((raw or "g").strip().lower(), "g")
|
||||||
|
|
||||||
|
|
||||||
|
def create_extension_attribute(cur, name_de: str, unit: str = "g", attr_key: str | None = None) -> dict[str, Any]:
|
||||||
|
name = (name_de or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Name fehlt")
|
||||||
|
key = normalize_attr_key(attr_key or name)
|
||||||
|
if not key:
|
||||||
|
raise ValueError("attr_key fehlt")
|
||||||
|
unit_n = normalize_attr_unit(unit)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
||||||
|
FROM food_attributes
|
||||||
|
WHERE attr_key = %s AND is_active = true
|
||||||
|
""",
|
||||||
|
(key,),
|
||||||
|
)
|
||||||
|
existing = cur.fetchone()
|
||||||
|
if existing:
|
||||||
|
return dict(existing)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO food_attributes
|
||||||
|
(attr_key, name_de, name_en, unit, category, data_type, origin, sort_order)
|
||||||
|
VALUES (%s, %s, %s, %s, 'extension', 'num_per_100g', 'extension', 9100)
|
||||||
|
RETURNING id, attr_key, name_de, name_en, unit, category, origin
|
||||||
|
""",
|
||||||
|
(key, name, None, unit_n),
|
||||||
|
)
|
||||||
|
return dict(cur.fetchone())
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ class SuggestBatchBody(BaseModel):
|
||||||
limit: int = 3
|
limit: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
class UserAttributeCreate(BaseModel):
|
||||||
|
name_de: str
|
||||||
|
unit: str = "g"
|
||||||
|
attr_key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
|
def _pid(session: dict, x_profile_id: Optional[str] = None) -> str:
|
||||||
return x_profile_id or session["profile_id"]
|
return x_profile_id or session["profile_id"]
|
||||||
|
|
||||||
|
|
@ -100,12 +106,26 @@ def search_foods(
|
||||||
@router.get("/attributes")
|
@router.get("/attributes")
|
||||||
def list_food_attributes(
|
def list_food_attributes(
|
||||||
q: str = "",
|
q: str = "",
|
||||||
|
keys: str = "",
|
||||||
limit: int = 40,
|
limit: int = 40,
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
from data_layer.food_attributes import list_numeric_attributes
|
from data_layer.food_attributes import list_numeric_attributes
|
||||||
|
key_list = [p.strip() for p in (keys or "").split(",") if p.strip()]
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
return list_numeric_attributes(get_cursor(conn), q, limit=limit)
|
return list_numeric_attributes(get_cursor(conn), q, limit=limit, keys=key_list or None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/attributes")
|
||||||
|
def create_food_attribute(body: UserAttributeCreate, session: dict = Depends(require_auth)):
|
||||||
|
from data_layer.food_attributes import create_extension_attribute
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
return create_extension_attribute(
|
||||||
|
get_cursor(conn), body.name_de, body.unit, body.attr_key
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/foods/suggest-batch")
|
@router.post("/foods/suggest-batch")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
from data_layer.food_attributes import macros_and_attributes_to_values, scale_to_per_100g
|
from data_layer.food_attributes import (
|
||||||
|
create_extension_attribute,
|
||||||
|
macros_and_attributes_to_values,
|
||||||
|
normalize_attr_key,
|
||||||
|
normalize_attr_unit,
|
||||||
|
scale_to_per_100g,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_scale_serving_to_per_100g():
|
def test_scale_serving_to_per_100g():
|
||||||
|
|
@ -7,6 +13,28 @@ def test_scale_serving_to_per_100g():
|
||||||
assert scale_to_per_100g(10, None) == 10
|
assert scale_to_per_100g(10, None) == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_attr_key_and_unit_normalize():
|
||||||
|
assert normalize_attr_key("Vitamin D3") == "VITAMIN_D3"
|
||||||
|
assert normalize_attr_unit("ug") == "µg"
|
||||||
|
assert normalize_attr_unit("mcg") == "µg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_extension_returns_existing():
|
||||||
|
class _Cur:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
self.calls += 1
|
||||||
|
self._row = {"id": 1, "attr_key": "VITAMIN_D3", "name_de": "Vitamin D3", "name_en": None, "unit": "µg", "category": "extension", "origin": "extension"} if self.calls == 1 else None
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return self._row
|
||||||
|
|
||||||
|
row = create_extension_attribute(_Cur(), "Vitamin D3", "µg")
|
||||||
|
assert row["attr_key"] == "VITAMIN_D3"
|
||||||
|
|
||||||
|
|
||||||
def test_norsan_label_epa_converts():
|
def test_norsan_label_epa_converts():
|
||||||
"""Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g."""
|
"""Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g."""
|
||||||
vals = macros_and_attributes_to_values(
|
vals = macros_and_attributes_to_values(
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ MODULE_VERSIONS = {
|
||||||
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
||||||
"nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS
|
"nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS
|
||||||
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
|
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
|
||||||
"bls": "1.0.5", # GET /bls/units
|
"bls": "1.0.6", # Nutzer darf Extension-Stoffe anlegen; EPA-Chips; mg/µg am Etikett
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
"prompts": "1.1.0",
|
"prompts": "1.1.0",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,35 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
||||||
|
const MASS = { g: 1, mg: 0.001, 'µg': 0.000001, ug: 0.000001, mcg: 0.000001 }
|
||||||
|
const QUICK_KEYS = ['EPA', 'DHA', 'DPA', 'OMEGA3']
|
||||||
|
|
||||||
function parseNum(raw) {
|
function parseNum(raw) {
|
||||||
const n = parseFloat(String(raw ?? '').replace(',', '.'))
|
const n = parseFloat(String(raw ?? '').replace(',', '.'))
|
||||||
return Number.isFinite(n) ? n : 0
|
return Number.isFinite(n) ? n : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attrUnit(attr) {
|
||||||
|
const u = (attr?.unit || 'g').trim()
|
||||||
|
if (u === 'ug' || u === 'mcg') return 'µg'
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertMass(value, fromUnit, toUnit) {
|
||||||
|
const from = MASS[(fromUnit || 'g').toLowerCase()]
|
||||||
|
const to = MASS[(toUnit || 'g').toLowerCase()]
|
||||||
|
if (!from || !to) return value
|
||||||
|
return value * (from / to)
|
||||||
|
}
|
||||||
|
|
||||||
export function foodCreatePayload(name, macros, extras, servingG) {
|
export function foodCreatePayload(name, macros, extras, servingG) {
|
||||||
const attributes = {}
|
const attributes = {}
|
||||||
for (const row of extras || []) {
|
for (const row of extras || []) {
|
||||||
if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue
|
if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue
|
||||||
attributes[row.attr.attr_key] = parseNum(row.value)
|
const n = parseNum(row.value)
|
||||||
|
const target = attrUnit(row.attr)
|
||||||
|
const entered = row.inputUnit || target
|
||||||
|
attributes[row.attr.attr_key] = convertMass(n, entered, target)
|
||||||
}
|
}
|
||||||
const serving = parseNum(servingG)
|
const serving = parseNum(servingG)
|
||||||
return {
|
return {
|
||||||
|
|
@ -29,10 +48,20 @@ export function foodCreatePayload(name, macros, extras, servingG) {
|
||||||
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) {
|
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) {
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [hits, setHits] = useState([])
|
const [hits, setHits] = useState([])
|
||||||
|
const [quick, setQuick] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [newName, setNewName] = useState('')
|
||||||
|
const [newUnit, setNewUnit] = useState('g')
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [createErr, setCreateErr] = useState(null)
|
||||||
const timer = useRef(null)
|
const timer = useRef(null)
|
||||||
|
|
||||||
useEffect(() => () => clearTimeout(timer.current), [])
|
useEffect(() => () => clearTimeout(timer.current), [])
|
||||||
|
useEffect(() => {
|
||||||
|
api.listFoodAttributes('', 20, QUICK_KEYS.join(',')).then((rows) => {
|
||||||
|
setQuick(Array.isArray(rows) ? rows : [])
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const search = (term) => {
|
const search = (term) => {
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
|
|
@ -55,7 +84,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
||||||
|
|
||||||
const add = (attr) => {
|
const add = (attr) => {
|
||||||
setExtras((list) => (
|
setExtras((list) => (
|
||||||
list.some((r) => r.attr.attr_key === attr.attr_key) ? list : [...list, { attr, value: '' }]
|
list.some((r) => r.attr.attr_key === attr.attr_key)
|
||||||
|
? list
|
||||||
|
: [...list, { attr, value: '', inputUnit: attrUnit(attr) }]
|
||||||
))
|
))
|
||||||
setQ('')
|
setQ('')
|
||||||
setHits([])
|
setHits([])
|
||||||
|
|
@ -65,10 +96,36 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
||||||
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r)))
|
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setInputUnit = (key, unit) => {
|
||||||
|
setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, inputUnit: unit } : r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAttr = async () => {
|
||||||
|
const name = (newName || q).trim()
|
||||||
|
if (!name) {
|
||||||
|
setCreateErr('Name des Stoffs fehlt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreating(true)
|
||||||
|
setCreateErr(null)
|
||||||
|
try {
|
||||||
|
const attr = await api.createFoodAttribute({ name_de: name, unit: newUnit })
|
||||||
|
add(attr)
|
||||||
|
setNewName('')
|
||||||
|
} catch (e) {
|
||||||
|
setCreateErr(e.message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unusedQuick = quick.filter((a) => !extras.some((r) => r.attr.attr_key === a.attr_key))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>
|
||||||
Zahlen in der Einheit des Stoffs. Bei Etikett „pro 8 ml / 8 g“ die Portionsgröße setzen — gespeichert wird immer pro 100 g.
|
Etikettwerte so eintragen, wie sie dastehen. Portionsgröße setzen (5 ml Öl ≈ 5 g). Gespeichert wird immer pro 100 g.
|
||||||
|
Ein Öl/Supplement ist ein Lebensmittel, keine Liste — Listen können keine Listen enthalten.
|
||||||
</p>
|
</p>
|
||||||
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
|
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
|
||||||
Angabe gilt für{' '}
|
Angabe gilt für{' '}
|
||||||
|
|
@ -81,7 +138,7 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
||||||
value={servingG}
|
value={servingG}
|
||||||
onChange={(e) => setServingG(e.target.value)}
|
onChange={(e) => setServingG(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{' '}g (100 = pro 100 g)
|
{' '}g (100 = pro 100 g; 5 ml Öl meist 5)
|
||||||
</label>
|
</label>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
|
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
|
||||||
|
|
@ -99,11 +156,20 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe (EPA, DHA, Ballaststoffe, …)</p>
|
<p style={{ fontSize: 12, fontWeight: 600, margin: '12px 0 6px' }}>Weitere Stoffe</p>
|
||||||
|
{unusedQuick.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||||
|
{unusedQuick.map((a) => (
|
||||||
|
<button key={a.attr_key} type="button" className="btn btn-secondary" style={{ fontSize: 12 }} onClick={() => add(a)}>
|
||||||
|
{a.attr_key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: '100%', textAlign: 'left' }}
|
style={{ width: '100%', textAlign: 'left' }}
|
||||||
placeholder="Stoff suchen, z. B. EPA oder Omega"
|
placeholder="Stoff suchen, z. B. EPA, Omega, Vitamin D"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => { setQ(e.target.value); search(e.target.value) }}
|
onChange={(e) => { setQ(e.target.value); search(e.target.value) }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -122,21 +188,63 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{extras.map((row) => (
|
<div style={{ marginTop: 10, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
||||||
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 6px' }}>Stoff nicht gefunden? Neu anlegen (wird für alle Lebensmittel nutzbar).</p>
|
||||||
{row.attr.name_de}{row.attr.unit ? ` (${row.attr.unit})` : ''}
|
<input
|
||||||
<input
|
className="form-input"
|
||||||
className="form-input"
|
style={{ width: '100%', textAlign: 'left', marginBottom: 6 }}
|
||||||
type="number"
|
placeholder="Name, z. B. Vitamin D3"
|
||||||
min="0"
|
value={newName}
|
||||||
step="0.001"
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
|
/>
|
||||||
value={row.value}
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
|
<select className="form-input" style={{ width: 90, textAlign: 'left' }} value={newUnit} onChange={(e) => setNewUnit(e.target.value)}>
|
||||||
placeholder="Wert in der Einheit oben"
|
<option value="g">g</option>
|
||||||
/>
|
<option value="mg">mg</option>
|
||||||
</label>
|
<option value="µg">µg</option>
|
||||||
))}
|
</select>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={creating} onClick={createAttr}>
|
||||||
|
{creating ? '…' : 'Parameter anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{createErr && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{createErr}</p>}
|
||||||
|
</div>
|
||||||
|
{extras.map((row) => {
|
||||||
|
const target = attrUnit(row.attr)
|
||||||
|
const entered = row.inputUnit || target
|
||||||
|
const mass = MASS[target.toLowerCase()]
|
||||||
|
return (
|
||||||
|
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
|
||||||
|
{row.attr.name_de} (Katalog: {target})
|
||||||
|
<span style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.001"
|
||||||
|
style={{ flex: 1, textAlign: 'left' }}
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
|
||||||
|
placeholder="Wert vom Etikett"
|
||||||
|
/>
|
||||||
|
{mass ? (
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: 72, textAlign: 'left' }}
|
||||||
|
value={entered}
|
||||||
|
onChange={(e) => setInputUnit(row.attr.attr_key, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="g">g</option>
|
||||||
|
<option value="mg">mg</option>
|
||||||
|
<option value="µg">µg</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span style={{ alignSelf: 'center' }}>{target}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -288,7 +288,8 @@ export const api = {
|
||||||
searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}),
|
searchBlsFoods: (q, limit=20, signal) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`, signal ? { signal } : {}),
|
||||||
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
|
suggestFoodsBatch: (names, limit=3) => req('/bls/foods/suggest-batch', json({ names, limit })),
|
||||||
createUserFood: (d) => req('/bls/foods/manual', json(d)),
|
createUserFood: (d) => req('/bls/foods/manual', json(d)),
|
||||||
listFoodAttributes: (q='', limit=40) => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}`),
|
listFoodAttributes: (q='', limit=40, keys='') => req(`/bls/attributes?q=${encodeURIComponent(q||'')}&limit=${limit}${keys ? `&keys=${encodeURIComponent(keys)}` : ''}`),
|
||||||
|
createFoodAttribute: (d) => req('/bls/attributes', json(d)),
|
||||||
listMyFoodMappings: () => req('/bls/mappings'),
|
listMyFoodMappings: () => req('/bls/mappings'),
|
||||||
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
|
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
|
||||||
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),
|
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user