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 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.
|
||||
- Ö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.
|
||||
- Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros.
|
||||
- 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
|
||||
|
||||
- `/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
|
||||
- `/api/admin/bls/*` — Import, Katalog, Attribute
|
||||
- `/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-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.
|
||||
- **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}`.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
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 = {
|
||||
"kcal": "ENERCC",
|
||||
"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))
|
||||
|
||||
|
||||
def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]:
|
||||
q = (query or "").strip()
|
||||
def list_numeric_attributes(
|
||||
cur, query: str = "", limit: int = 40, keys: list[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
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:
|
||||
like = f"%{q}%"
|
||||
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())),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
return x_profile_id or session["profile_id"]
|
||||
|
||||
|
|
@ -100,12 +106,26 @@ def search_foods(
|
|||
@router.get("/attributes")
|
||||
def list_food_attributes(
|
||||
q: str = "",
|
||||
keys: str = "",
|
||||
limit: int = 40,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
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:
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
@ -7,6 +13,28 @@ def test_scale_serving_to_per_100g():
|
|||
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():
|
||||
"""Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g."""
|
||||
vals = macros_and_attributes_to_values(
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ MODULE_VERSIONS = {
|
|||
"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
|
||||
"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",
|
||||
"insights": "1.3.0",
|
||||
"prompts": "1.1.0",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,35 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
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) {
|
||||
const n = parseFloat(String(raw ?? '').replace(',', '.'))
|
||||
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) {
|
||||
const attributes = {}
|
||||
for (const row of extras || []) {
|
||||
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)
|
||||
return {
|
||||
|
|
@ -29,10 +48,20 @@ export function foodCreatePayload(name, macros, extras, servingG) {
|
|||
export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) {
|
||||
const [q, setQ] = useState('')
|
||||
const [hits, setHits] = useState([])
|
||||
const [quick, setQuick] = useState([])
|
||||
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)
|
||||
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
useEffect(() => {
|
||||
api.listFoodAttributes('', 20, QUICK_KEYS.join(',')).then((rows) => {
|
||||
setQuick(Array.isArray(rows) ? rows : [])
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const search = (term) => {
|
||||
clearTimeout(timer.current)
|
||||
|
|
@ -55,7 +84,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
|
||||
const add = (attr) => {
|
||||
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('')
|
||||
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)))
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<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>
|
||||
<label style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginBottom: 8 }}>
|
||||
Angabe gilt für{' '}
|
||||
|
|
@ -81,7 +138,7 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
value={servingG}
|
||||
onChange={(e) => setServingG(e.target.value)}
|
||||
/>
|
||||
{' '}g (100 = pro 100 g)
|
||||
{' '}g (100 = pro 100 g; 5 ml Öl meist 5)
|
||||
</label>
|
||||
<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]) => (
|
||||
|
|
@ -99,11 +156,20 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
</label>
|
||||
))}
|
||||
</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
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left' }}
|
||||
placeholder="Stoff suchen, z. B. EPA oder Omega"
|
||||
placeholder="Stoff suchen, z. B. EPA, Omega, Vitamin D"
|
||||
value={q}
|
||||
onChange={(e) => { setQ(e.target.value); search(e.target.value) }}
|
||||
/>
|
||||
|
|
@ -122,21 +188,63 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra
|
|||
</span>
|
||||
</button>
|
||||
))}
|
||||
{extras.map((row) => (
|
||||
<label key={row.attr.attr_key} style={{ display: 'block', fontSize: 12, color: 'var(--text2)', marginTop: 8 }}>
|
||||
{row.attr.name_de}{row.attr.unit ? ` (${row.attr.unit})` : ''}
|
||||
<input
|
||||
className="form-input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.001"
|
||||
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
|
||||
value={row.value}
|
||||
onChange={(e) => setValue(row.attr.attr_key, e.target.value)}
|
||||
placeholder="Wert in der Einheit oben"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div style={{ marginTop: 10, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '0 0 6px' }}>Stoff nicht gefunden? Neu anlegen (wird für alle Lebensmittel nutzbar).</p>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left', marginBottom: 6 }}
|
||||
placeholder="Name, z. B. Vitamin D3"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select className="form-input" style={{ width: 90, textAlign: 'left' }} value={newUnit} onChange={(e) => setNewUnit(e.target.value)}>
|
||||
<option value="g">g</option>
|
||||
<option value="mg">mg</option>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,7 +288,8 @@ export const api = {
|
|||
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 })),
|
||||
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'),
|
||||
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
|
||||
deleteMyFoodMapping: (id) => req(`/bls/mappings/${id}`, {method:'DELETE'}),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user