feat: Zuordnen ohne Freeze und zuerst die letzten Wochen
Katalogsuche und Bestätigen blockieren die UI nicht mehr; offene Namen starten bei den aktuellen Tagebucheinträgen, alter Ballast bleibt unter Alle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c1873a47b1
commit
132a364a3a
|
|
@ -8,7 +8,7 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte
|
||||||
|
|
||||||
## Zuordnung (UX)
|
## Zuordnung (UX)
|
||||||
|
|
||||||
Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen. Fehlt ein Treffer, kann ein **eigener Katalogeintrag** (Name + Makros/100 g) angelegt und sofort zugeordnet werden. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping.
|
Offene Zuordnungen zeigen **Vorschläge in der Zeile** (z. B. Haferflocken → Hafer Flocken); Bestätigen ohne Dialog. Mehrere nahe Treffer werden gekennzeichnet. Fehlt ein Treffer (z. B. Salz), **Neu anlegen** in derselben Zeile. Zusätzlich Popup-Suche. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. Vorschläge und Katalogsuche laufen nur für die sichtbare Arbeit, nicht über alle offenen Namen auf einmal. Die Offene-Liste startet bei den **letzten 4 Wochen**; ältere Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“ und müssen nicht gemappt werden.
|
||||||
|
|
||||||
## FDDB-Listen / eigene Rezepte
|
## FDDB-Listen / eigene Rezepte
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,8 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
|
||||||
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
- `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve
|
||||||
- `GET /api/nutrition/recipes`, `POST /api/nutrition/recipes/import-fddb-lists`, `POST /api/nutrition/recipes/{id}/apply`
|
- `GET /api/nutrition/recipes`, `POST /api/nutrition/recipes/import-fddb-lists`, `POST /api/nutrition/recipes/{id}/apply`
|
||||||
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping
|
- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping
|
||||||
- Frontend: `FoodSearchModal` (Name-Suche, eigener Eintrag, Gramm/Einheit), Listen-Import auf dem Tab Zuordnen
|
- Frontend: Inline-Vorschläge auf Zuordnen (`food_suggest.py`: Collapse-Key ohne Leerzeichen, Index 5 Min. Cache), `FoodSearchModal` nur noch Zusatzsuche (Abort + Debounce)
|
||||||
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen (Rebuild darf das Mapping nicht zurückrollen)
|
- `GET /nutrition/unmapped?since_days=28` — nur Namen mit `last_date` im Fenster; `count_only` liefert `{count, total, since_days}`; `POST /bls/foods/suggest-batch` für sichtbare Zeilen (max. 80)
|
||||||
|
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar)
|
||||||
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
|
- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**)
|
||||||
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).
|
- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein).
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ frontend/src/
|
||||||
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
|
- **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil.
|
||||||
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
|
- **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings.
|
||||||
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
|
- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; Fasten/Lücke; Import-Abgleich.
|
||||||
|
- **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.
|
||||||
|
- **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,8 @@ def upsert_foods(cur, foods: list[dict[str, Any]], bls_version: str = "4.0") ->
|
||||||
page_size=VALUE_PAGE,
|
page_size=VALUE_PAGE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from data_layer.food_suggest import invalidate_suggest_index
|
||||||
|
invalidate_suggest_index()
|
||||||
return {
|
return {
|
||||||
"inserted": inserted,
|
"inserted": inserted,
|
||||||
"updated": updated,
|
"updated": updated,
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,8 @@ def import_food_knowledge(cur, profile_id: str, data: dict[str, Any]) -> dict[st
|
||||||
dates.update(recipe_stats.pop("dates_linked", []) or [])
|
dates.update(recipe_stats.pop("dates_linked", []) or [])
|
||||||
for day in dates:
|
for day in dates:
|
||||||
rebuild_daily_nutrients(cur, profile_id, day)
|
rebuild_daily_nutrients(cur, profile_id, day)
|
||||||
|
from data_layer.food_suggest import invalidate_suggest_index
|
||||||
|
invalidate_suggest_index(profile_id)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"manual_foods": foods_upserted,
|
"manual_foods": foods_upserted,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from datetime import date, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
LEADING_QTY_RE = re.compile(
|
LEADING_QTY_RE = re.compile(
|
||||||
|
|
@ -64,6 +65,44 @@ def merge_unmapped_rows(rows: list[dict]) -> list[dict]:
|
||||||
return list(merged.values())
|
return list(merged.values())
|
||||||
|
|
||||||
|
|
||||||
|
def as_iso_date(value: Any) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if hasattr(value, "isoformat"):
|
||||||
|
return str(value.isoformat())[:10]
|
||||||
|
text = str(value).strip()
|
||||||
|
return text[:10] if len(text) >= 10 else None
|
||||||
|
|
||||||
|
|
||||||
|
def filter_unmapped_since(rows: list[dict], since_days: int, today: date | None = None) -> list[dict]:
|
||||||
|
"""Keep names last eaten in the window. Rows without last_date drop out (old recipe leftovers)."""
|
||||||
|
days = int(since_days or 0)
|
||||||
|
if days <= 0:
|
||||||
|
return list(rows)
|
||||||
|
cutoff = ((today or date.today()) - timedelta(days=days)).isoformat()
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
last = as_iso_date(row.get("last_date"))
|
||||||
|
if last and last >= cutoff:
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def sort_unmapped_rows(rows: list[dict], since_days: int = 0) -> list[dict]:
|
||||||
|
rows = list(rows)
|
||||||
|
if int(since_days or 0) > 0:
|
||||||
|
rows.sort(
|
||||||
|
key=lambda x: (
|
||||||
|
as_iso_date(x.get("last_date")) or "",
|
||||||
|
int(x.get("count") or 0),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
UNIT_ALIASES = {
|
UNIT_ALIASES = {
|
||||||
"g": "g", "gr": "g", "gramm": "g",
|
"g": "g", "gr": "g", "gramm": "g",
|
||||||
"kg": "kg",
|
"kg": "kg",
|
||||||
|
|
|
||||||
214
backend/data_layer/food_suggest.py
Normal file
214
backend/data_layer/food_suggest.py
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
"""In-memory catalog suggestions: Haferflocken → Hafer Flocken, without opening a dialog."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from data_layer.food_mapping import normalize_food_name
|
||||||
|
|
||||||
|
_INDEX_TTL_SEC = 300
|
||||||
|
_index_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||||
|
MAX_CANDIDATES = 60
|
||||||
|
MAX_BUCKET = 40
|
||||||
|
|
||||||
|
SPLIT_RE = re.compile(r"[^a-z0-9äöüß]+")
|
||||||
|
COLLAPSE_RE = re.compile(r"[^a-z0-9äöüß]")
|
||||||
|
MIN_SCORE = 45
|
||||||
|
|
||||||
|
|
||||||
|
def collapse_key(raw: str | None) -> str:
|
||||||
|
return COLLAPSE_RE.sub("", normalize_food_name(raw))
|
||||||
|
|
||||||
|
|
||||||
|
def name_tokens(raw: str | None) -> list[str]:
|
||||||
|
return [t for t in SPLIT_RE.split(normalize_food_name(raw)) if len(t) >= 2]
|
||||||
|
|
||||||
|
|
||||||
|
def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int:
|
||||||
|
qn = normalize_food_name((query or "").split(",")[0])
|
||||||
|
nn = normalize_food_name(name_de)
|
||||||
|
if not qn or not nn:
|
||||||
|
return 0
|
||||||
|
qc, nc = collapse_key(qn), collapse_key(nn)
|
||||||
|
if qn == nn:
|
||||||
|
return 100
|
||||||
|
if qc and qc == nc:
|
||||||
|
return 95
|
||||||
|
if qc and nc.startswith(qc) and len(qc) >= 4:
|
||||||
|
return 82
|
||||||
|
if nc and qc.startswith(nc) and len(nc) >= 4:
|
||||||
|
return 78
|
||||||
|
qt, nt = set(name_tokens(qn)), set(name_tokens(nn))
|
||||||
|
if qt and qt <= nt:
|
||||||
|
return 72
|
||||||
|
if nt and nt <= qt:
|
||||||
|
return 68
|
||||||
|
if qt and nt:
|
||||||
|
overlap = len(qt & nt) / len(qt | nt)
|
||||||
|
if overlap >= 0.5:
|
||||||
|
return 50 + int(overlap * 20)
|
||||||
|
if qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
|
||||||
|
return 55 if abs(len(qc) - len(nc)) <= 8 else 46
|
||||||
|
en = normalize_food_name(name_en or "")
|
||||||
|
if en and (qn == en or collapse_key(en) == qc):
|
||||||
|
return 88
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": str(food["id"]),
|
||||||
|
"bls_code": food.get("bls_code"),
|
||||||
|
"name_de": food.get("name_de"),
|
||||||
|
"name_en": food.get("name_en"),
|
||||||
|
"catalog_kind": food.get("catalog_kind"),
|
||||||
|
"food_group": food.get("food_group"),
|
||||||
|
"score": score,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_suggest_index(profile_id: str | None = None) -> None:
|
||||||
|
if profile_id is None:
|
||||||
|
_index_cache.clear()
|
||||||
|
return
|
||||||
|
_index_cache.pop(str(profile_id), None)
|
||||||
|
_index_cache.pop("global", None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
|
||||||
|
key = str(profile_id or "global")
|
||||||
|
hit = _index_cache.get(key)
|
||||||
|
if hit and (time.monotonic() - hit[0]) < _INDEX_TTL_SEC:
|
||||||
|
return hit[1]
|
||||||
|
index = load_suggest_index(cur, profile_id)
|
||||||
|
_index_cache[key] = (time.monotonic(), index)
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, bls_code, name_de, name_en, catalog_kind, food_group
|
||||||
|
FROM food_catalog
|
||||||
|
WHERE is_active = true
|
||||||
|
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
foods = [dict(r) for r in cur.fetchall()]
|
||||||
|
by_collapse: dict[str, list] = {}
|
||||||
|
by_token: dict[str, list] = {}
|
||||||
|
by_prefix: dict[str, list] = {}
|
||||||
|
by_suffix: dict[str, list] = {}
|
||||||
|
for food in foods:
|
||||||
|
food["_c"] = collapse_key(food.get("name_de"))
|
||||||
|
food["_t"] = name_tokens(food.get("name_de"))
|
||||||
|
if food["_c"]:
|
||||||
|
by_collapse.setdefault(food["_c"], []).append(food)
|
||||||
|
by_prefix.setdefault(food["_c"][:4], []).append(food)
|
||||||
|
if len(food["_c"]) >= 4:
|
||||||
|
by_suffix.setdefault(food["_c"][-4:], []).append(food)
|
||||||
|
for tok in food["_t"]:
|
||||||
|
by_token.setdefault(tok, []).append(food)
|
||||||
|
return {
|
||||||
|
"foods": foods,
|
||||||
|
"by_collapse": by_collapse,
|
||||||
|
"by_token": by_token,
|
||||||
|
"by_prefix": by_prefix,
|
||||||
|
"by_suffix": by_suffix,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]:
|
||||||
|
qc = collapse_key(query)
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def add(food: dict[str, Any]) -> None:
|
||||||
|
fid = str(food["id"])
|
||||||
|
if fid in seen:
|
||||||
|
return
|
||||||
|
seen.add(fid)
|
||||||
|
out.append(food)
|
||||||
|
|
||||||
|
if qc:
|
||||||
|
for food in index["by_collapse"].get(qc, []):
|
||||||
|
add(food)
|
||||||
|
if len(qc) >= 4:
|
||||||
|
prefix_hits = index["by_prefix"].get(qc[:4], [])
|
||||||
|
if len(prefix_hits) > MAX_BUCKET:
|
||||||
|
prefix_hits = [
|
||||||
|
food for food in prefix_hits
|
||||||
|
if (food.get("_c") or "").startswith(qc) or qc.startswith(food.get("_c") or "")
|
||||||
|
]
|
||||||
|
for food in prefix_hits[:MAX_CANDIDATES]:
|
||||||
|
add(food)
|
||||||
|
suffix_hits = index["by_suffix"].get(qc[-4:], [])
|
||||||
|
if len(suffix_hits) <= MAX_BUCKET:
|
||||||
|
for food in suffix_hits:
|
||||||
|
add(food)
|
||||||
|
for tok in name_tokens(query):
|
||||||
|
token_hits = index["by_token"].get(tok, [])
|
||||||
|
if len(token_hits) > MAX_BUCKET:
|
||||||
|
token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET]
|
||||||
|
for food in token_hits:
|
||||||
|
add(food)
|
||||||
|
if len(out) >= MAX_CANDIDATES:
|
||||||
|
break
|
||||||
|
return out[:MAX_CANDIDATES]
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
|
||||||
|
q = (query or "").strip()
|
||||||
|
scored: list[tuple[int, int, dict]] = []
|
||||||
|
for food in _candidate_foods(index, q):
|
||||||
|
score = score_name_match(q, food.get("name_de") or "", food.get("name_en"))
|
||||||
|
if score < MIN_SCORE:
|
||||||
|
continue
|
||||||
|
scored.append((score, len(food.get("name_de") or ""), food))
|
||||||
|
scored.sort(key=lambda x: (-x[0], x[1], x[2].get("name_de") or ""))
|
||||||
|
top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]]
|
||||||
|
ambiguous = False
|
||||||
|
if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60:
|
||||||
|
ambiguous = True
|
||||||
|
elif len(top) >= 2 and top[0]["score"] < 90:
|
||||||
|
ambiguous = True
|
||||||
|
return {
|
||||||
|
"suggestions": top[:limit],
|
||||||
|
"suggestion_count": len(top),
|
||||||
|
"ambiguous": ambiguous,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def attach_suggestions(index: dict[str, Any], rows: list[dict[str, Any]], limit: int = 3) -> list[dict[str, Any]]:
|
||||||
|
for row in rows:
|
||||||
|
q = row.get("source_name_raw") or row.get("source_name_normalized") or ""
|
||||||
|
packed = suggest_for_name(index, q, limit=limit)
|
||||||
|
row["suggestions"] = packed["suggestions"]
|
||||||
|
row["suggestion_count"] = packed["suggestion_count"]
|
||||||
|
row["ambiguous"] = packed["ambiguous"]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
|
||||||
|
q = (query or "").strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
return []
|
||||||
|
index = get_suggest_index(cur, profile_id)
|
||||||
|
packed = suggest_for_name(index, q, limit=limit)
|
||||||
|
if packed["suggestions"]:
|
||||||
|
return packed["suggestions"]
|
||||||
|
from data_layer.food_mapping import suggest_catalog_foods
|
||||||
|
return suggest_catalog_foods(cur, q, profile_id, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
def suggest_batch(cur, profile_id: str | None, names: list[str], limit: int = 3) -> dict[str, dict[str, Any]]:
|
||||||
|
index = get_suggest_index(cur, profile_id)
|
||||||
|
out = {}
|
||||||
|
for raw in names[:100]:
|
||||||
|
key = (raw or "").strip()
|
||||||
|
if not key or key in out:
|
||||||
|
continue
|
||||||
|
out[key] = suggest_for_name(index, key, limit=limit)
|
||||||
|
return out
|
||||||
|
|
@ -216,6 +216,8 @@ def admin_create_manual_food(body: ManualFoodCreate, session: dict = Depends(req
|
||||||
)
|
)
|
||||||
food = r2d(cur.fetchone())
|
food = r2d(cur.fetchone())
|
||||||
_write_manual_macros(cur, food["id"], body.macros_per_100g)
|
_write_manual_macros(cur, food["id"], body.macros_per_100g)
|
||||||
|
from data_layer.food_suggest import invalidate_suggest_index
|
||||||
|
invalidate_suggest_index()
|
||||||
return food
|
return food
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
|
@ -13,9 +14,13 @@ from data_layer.food_mapping import (
|
||||||
apply_quantities_to_items,
|
apply_quantities_to_items,
|
||||||
clear_mapping_from_items,
|
clear_mapping_from_items,
|
||||||
normalize_food_name,
|
normalize_food_name,
|
||||||
suggest_catalog_foods,
|
|
||||||
upsert_food_mapping,
|
upsert_food_mapping,
|
||||||
)
|
)
|
||||||
|
from data_layer.food_suggest import (
|
||||||
|
invalidate_suggest_index,
|
||||||
|
suggest_batch,
|
||||||
|
suggest_catalog_foods_ranked,
|
||||||
|
)
|
||||||
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
|
from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients
|
||||||
from db import get_cursor, get_db, r2d
|
from db import get_cursor, get_db, r2d
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
|
@ -39,6 +44,11 @@ class MappingUpsert(BaseModel):
|
||||||
source_unit: Optional[str] = None
|
source_unit: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestBatchBody(BaseModel):
|
||||||
|
names: list[str]
|
||||||
|
limit: int = 3
|
||||||
|
|
||||||
|
|
||||||
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"]
|
||||||
|
|
||||||
|
|
@ -48,6 +58,25 @@ def _rebuild_days(cur, profile_id: str, dates: list[str]) -> None:
|
||||||
rebuild_daily_nutrients(cur, profile_id, d)
|
rebuild_daily_nutrients(cur, profile_id, d)
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_days_bg(profile_id: str, dates: list[str], context: str) -> None:
|
||||||
|
if not dates:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
_rebuild_days(get_cursor(conn), profile_id, dates)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Nährwert-Rebuild nach %s fehlgeschlagen", context)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_rebuild(profile_id: str, dates: list[str], context: str) -> None:
|
||||||
|
threading.Thread(
|
||||||
|
target=_rebuild_days_bg,
|
||||||
|
args=(profile_id, list(dates), context),
|
||||||
|
daemon=True,
|
||||||
|
name="nutrition-rebuild",
|
||||||
|
).start()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/foods")
|
@router.get("/foods")
|
||||||
def search_foods(
|
def search_foods(
|
||||||
q: str = "",
|
q: str = "",
|
||||||
|
|
@ -57,7 +86,19 @@ def search_foods(
|
||||||
pid = session["profile_id"]
|
pid = session["profile_id"]
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
return suggest_catalog_foods(cur, q, pid, limit=min(max(limit, 1), 50))
|
return suggest_catalog_foods_ranked(cur, q, pid, limit=min(max(limit, 1), 50))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/foods/suggest-batch")
|
||||||
|
def suggest_foods_batch(
|
||||||
|
body: SuggestBatchBody,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
names = [n for n in (body.names or []) if isinstance(n, str)][:80]
|
||||||
|
limit = min(max(body.limit or 3, 1), 5)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
return suggest_batch(cur, session["profile_id"], names, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/foods/manual")
|
@router.post("/foods/manual")
|
||||||
|
|
@ -86,6 +127,7 @@ def create_user_food(
|
||||||
)
|
)
|
||||||
food = r2d(cur.fetchone())
|
food = r2d(cur.fetchone())
|
||||||
_write_manual_macros(cur, food["id"], body.macros_per_100g)
|
_write_manual_macros(cur, food["id"], body.macros_per_100g)
|
||||||
|
invalidate_suggest_index(pid)
|
||||||
return food
|
return food
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -123,13 +165,14 @@ def upsert_my_mapping(
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM food_catalog
|
SELECT id, name_de, bls_code, catalog_kind FROM food_catalog
|
||||||
WHERE id = %s AND is_active = true
|
WHERE id = %s AND is_active = true
|
||||||
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
|
AND (owner_profile_id IS NULL OR owner_profile_id = %s)
|
||||||
""",
|
""",
|
||||||
(body.food_id, pid),
|
(body.food_id, pid),
|
||||||
)
|
)
|
||||||
if not cur.fetchone():
|
food = cur.fetchone()
|
||||||
|
if not food:
|
||||||
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
raise HTTPException(404, "Lebensmittel nicht gefunden")
|
||||||
mid = upsert_food_mapping(
|
mid = upsert_food_mapping(
|
||||||
cur,
|
cur,
|
||||||
|
|
@ -145,12 +188,16 @@ def upsert_my_mapping(
|
||||||
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
|
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
|
||||||
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
|
apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
|
||||||
dates = dates_for_normalized_name(cur, pid, norm)
|
dates = dates_for_normalized_name(cur, pid, norm)
|
||||||
try:
|
_schedule_rebuild(pid, dates, f"Mapping {norm}")
|
||||||
with get_db() as conn:
|
return {
|
||||||
_rebuild_days(get_cursor(conn), pid, dates)
|
"mapping_id": mid,
|
||||||
except Exception:
|
"items_updated": n,
|
||||||
logger.exception("Nährwert-Rebuild nach Mapping %s fehlgeschlagen", norm)
|
"source_name_normalized": norm,
|
||||||
return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm}
|
"food_id": body.food_id,
|
||||||
|
"food_name_de": food["name_de"],
|
||||||
|
"bls_code": food.get("bls_code"),
|
||||||
|
"catalog_kind": food.get("catalog_kind"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/mappings/{mapping_id}")
|
@router.delete("/mappings/{mapping_id}")
|
||||||
|
|
@ -176,11 +223,7 @@ def delete_my_mapping(
|
||||||
dates = dates_for_normalized_name(cur, pid, norm)
|
dates = dates_for_normalized_name(cur, pid, norm)
|
||||||
clear_mapping_from_items(cur, pid, norm)
|
clear_mapping_from_items(cur, pid, norm)
|
||||||
cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid))
|
cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid))
|
||||||
try:
|
_schedule_rebuild(pid, dates, "Mapping-Löschen")
|
||||||
with get_db() as conn:
|
|
||||||
_rebuild_days(get_cursor(conn), pid, dates)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Nährwert-Rebuild nach Mapping-Löschen fehlgeschlagen")
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,10 +347,18 @@ def list_nutrition_items(
|
||||||
|
|
||||||
@router.get("/unmapped")
|
@router.get("/unmapped")
|
||||||
def list_unmapped_foods(
|
def list_unmapped_foods(
|
||||||
|
since_days: int = 0,
|
||||||
|
count_only: bool = False,
|
||||||
x_profile_id: Optional[str] = Header(default=None),
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name
|
from data_layer.food_mapping import (
|
||||||
|
as_iso_date,
|
||||||
|
filter_unmapped_since,
|
||||||
|
merge_unmapped_rows,
|
||||||
|
normalize_food_name,
|
||||||
|
sort_unmapped_rows,
|
||||||
|
)
|
||||||
|
|
||||||
pid = x_profile_id or session["profile_id"]
|
pid = x_profile_id or session["profile_id"]
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
|
|
@ -383,18 +391,27 @@ def list_unmapped_foods(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT i.source_name_raw, i.source_name_normalized,
|
SELECT i.source_name_raw, i.source_name_normalized,
|
||||||
COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date,
|
COUNT(*) AS count, u.first_used AS first_date, u.last_used AS last_date,
|
||||||
MIN(i.quantity_raw) AS sample_quantity_raw
|
MIN(i.quantity_raw) AS sample_quantity_raw
|
||||||
FROM food_recipe_ingredients i
|
FROM food_recipe_ingredients i
|
||||||
JOIN food_recipes r ON r.id = i.recipe_id
|
JOIN food_recipes r ON r.id = i.recipe_id
|
||||||
LEFT JOIN food_name_mappings m
|
LEFT JOIN food_name_mappings m
|
||||||
ON m.profile_id = r.profile_id
|
ON m.profile_id = r.profile_id
|
||||||
AND m.source_name_normalized = i.source_name_normalized
|
AND m.source_name_normalized = i.source_name_normalized
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT r2.id AS recipe_id, MIN(ni.date) AS first_used, MAX(ni.date) AS last_used
|
||||||
|
FROM food_recipes r2
|
||||||
|
JOIN nutrition_items ni
|
||||||
|
ON ni.profile_id = r2.profile_id
|
||||||
|
AND (ni.recipe_id = r2.id OR ni.source_name_normalized = r2.name_normalized)
|
||||||
|
WHERE r2.profile_id = %s
|
||||||
|
GROUP BY r2.id
|
||||||
|
) u ON u.recipe_id = r.id
|
||||||
WHERE r.profile_id = %s AND m.id IS NULL
|
WHERE r.profile_id = %s AND m.id IS NULL
|
||||||
GROUP BY i.source_name_raw, i.source_name_normalized
|
GROUP BY i.source_name_raw, i.source_name_normalized, u.first_used, u.last_used
|
||||||
ORDER BY count DESC, i.source_name_normalized
|
ORDER BY count DESC, i.source_name_normalized
|
||||||
""",
|
""",
|
||||||
(pid,),
|
(pid, pid),
|
||||||
)
|
)
|
||||||
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
|
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
|
||||||
merged = merge_unmapped_rows(diary + ings)
|
merged = merge_unmapped_rows(diary + ings)
|
||||||
|
|
@ -403,9 +420,14 @@ def list_unmapped_foods(
|
||||||
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
|
key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
|
||||||
if key in mapped:
|
if key in mapped:
|
||||||
continue
|
continue
|
||||||
|
row["last_date"] = as_iso_date(row.get("last_date"))
|
||||||
|
row["first_date"] = as_iso_date(row.get("first_date"))
|
||||||
out.append(row)
|
out.append(row)
|
||||||
out.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or ""))
|
days = max(0, min(int(since_days or 0), 3650))
|
||||||
return out
|
recent = sort_unmapped_rows(filter_unmapped_since(out, days), days)
|
||||||
|
if count_only:
|
||||||
|
return {"count": len(recent), "total": len(out), "since_days": days}
|
||||||
|
return recent if days else sort_unmapped_rows(out, 0)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recipes")
|
@router.get("/recipes")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from csv_parser.executor import guess_nutrition_item_fields
|
from csv_parser.executor import guess_nutrition_item_fields
|
||||||
from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name, parse_quantity, parse_quantity_g
|
from data_layer.food_mapping import (
|
||||||
|
filter_unmapped_since,
|
||||||
|
merge_unmapped_rows,
|
||||||
|
normalize_food_name,
|
||||||
|
parse_quantity,
|
||||||
|
parse_quantity_g,
|
||||||
|
)
|
||||||
from data_layer.nutrition_items import macros_differ
|
from data_layer.nutrition_items import macros_differ
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -42,6 +50,18 @@ def test_guess_fddb_bezeichnung_without_template_mapping():
|
||||||
assert qty == "50 g"
|
assert qty == "50 g"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_window_drops_old_and_dateless_foods():
|
||||||
|
today = date(2026, 9, 12)
|
||||||
|
rows = [
|
||||||
|
{"source_name_raw": "Haferflocken", "last_date": "2026-09-10", "count": 2},
|
||||||
|
{"source_name_raw": "Weißbrot", "last_date": "2026-01-02", "count": 40},
|
||||||
|
{"source_name_raw": "Altes Rezept-Salz", "last_date": None, "count": 1},
|
||||||
|
]
|
||||||
|
recent = filter_unmapped_since(rows, 28, today=today)
|
||||||
|
assert [r["source_name_raw"] for r in recent] == ["Haferflocken"]
|
||||||
|
assert len(filter_unmapped_since(rows, 0, today=today)) == 3
|
||||||
|
|
||||||
|
|
||||||
def test_macros_differ_rounds():
|
def test_macros_differ_rounds():
|
||||||
assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
|
assert not macros_differ({"kcal": 1.04, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 1.0, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
|
||||||
assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
|
assert macros_differ({"kcal": 10, "protein_g": 0, "fat_g": 0, "carbs_g": 0}, {"kcal": 11, "protein_g": 0, "fat_g": 0, "carbs_g": 0})
|
||||||
|
|
|
||||||
65
backend/tests/test_food_suggest.py
Normal file
65
backend/tests/test_food_suggest.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from data_layer.food_suggest import collapse_key, score_name_match, suggest_batch, suggest_for_name
|
||||||
|
|
||||||
|
|
||||||
|
def test_collapse_treats_space_as_same():
|
||||||
|
assert collapse_key("Haferflocken") == collapse_key("Hafer Flocken")
|
||||||
|
|
||||||
|
|
||||||
|
def test_haferflocken_ranks_simple_name_first():
|
||||||
|
index = {
|
||||||
|
"foods": [],
|
||||||
|
"by_collapse": {},
|
||||||
|
"by_token": {},
|
||||||
|
"by_prefix": {},
|
||||||
|
"by_suffix": {},
|
||||||
|
}
|
||||||
|
simple = {"id": "1", "name_de": "Hafer Flocken", "name_en": "oat flakes", "bls_code": "C131111", "catalog_kind": "official_bls"}
|
||||||
|
dish = {"id": "2", "name_de": "Milch-Getreide-Brei, mit Haferflocken und Apfelsaft (geeignet für Beikost)", "name_en": "", "bls_code": "X", "catalog_kind": "official_bls"}
|
||||||
|
for food in (simple, dish):
|
||||||
|
food["_c"] = collapse_key(food["name_de"])
|
||||||
|
index["by_collapse"].setdefault(food["_c"], []).append(food)
|
||||||
|
index["by_prefix"].setdefault(food["_c"][:4], []).append(food)
|
||||||
|
index["by_suffix"].setdefault(food["_c"][-4:], []).append(food)
|
||||||
|
packed = suggest_for_name(index, "Haferflocken", limit=3)
|
||||||
|
assert packed["suggestions"][0]["name_de"] == "Hafer Flocken"
|
||||||
|
assert packed["suggestions"][0]["score"] >= score_name_match("Haferflocken", dish["name_de"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_scores_are_ambiguous():
|
||||||
|
a = {"id": "1", "name_de": "Olivenöl nativ", "name_en": "", "bls_code": "A", "catalog_kind": "official_bls"}
|
||||||
|
b = {"id": "2", "name_de": "Olivenöl raffiniert", "name_en": "", "bls_code": "B", "catalog_kind": "official_bls"}
|
||||||
|
index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}}
|
||||||
|
for food in (a, b):
|
||||||
|
food["_c"] = collapse_key(food["name_de"])
|
||||||
|
index["by_prefix"].setdefault(food["_c"][:4], []).append(food)
|
||||||
|
for tok in food["name_de"].lower().replace(",", "").split():
|
||||||
|
if len(tok) >= 2:
|
||||||
|
index["by_token"].setdefault(tok, []).append(food)
|
||||||
|
packed = suggest_for_name(index, "Olivenöl", limit=3)
|
||||||
|
assert packed["ambiguous"] is True
|
||||||
|
assert packed["suggestion_count"] >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_large_prefix_bucket_keeps_exact_collapse():
|
||||||
|
index = {"foods": [], "by_collapse": {}, "by_token": {}, "by_prefix": {}, "by_suffix": {}}
|
||||||
|
target = {"id": "hit", "name_de": "Hafer Flocken", "name_en": "", "bls_code": "C", "catalog_kind": "official_bls"}
|
||||||
|
target["_c"] = collapse_key(target["name_de"])
|
||||||
|
index["by_collapse"][target["_c"]] = [target]
|
||||||
|
index["by_prefix"][target["_c"][:4]] = [target] + [
|
||||||
|
{"id": str(i), "name_de": f"Hafer Gericht {i}", "name_en": "", "_c": f"hafergericht{i}"}
|
||||||
|
for i in range(80)
|
||||||
|
]
|
||||||
|
packed = suggest_for_name(index, "Haferflocken", limit=3)
|
||||||
|
assert packed["suggestions"][0]["name_de"] == "Hafer Flocken"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suggest_batch_dedupes_names():
|
||||||
|
class _Cur:
|
||||||
|
def execute(self, *args, **kwargs):
|
||||||
|
return None
|
||||||
|
def fetchall(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
out = suggest_batch(_Cur(), None, ["Haferflocken", "Haferflocken", ""], limit=2)
|
||||||
|
assert "Haferflocken" in out
|
||||||
|
assert out["Haferflocken"]["suggestions"] == []
|
||||||
|
|
@ -20,8 +20,8 @@ MODULE_VERSIONS = {
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
"caliper": "1.0.1",
|
"caliper": "1.0.1",
|
||||||
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement
|
||||||
"nutrition": "1.2.2", # mapping save + manual food + units
|
"nutrition": "1.2.5", # unmapped since_days: last weeks first
|
||||||
"bls": "1.0.1",
|
"bls": "1.0.2",
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
"prompts": "1.1.0",
|
"prompts": "1.1.0",
|
||||||
|
|
@ -49,6 +49,9 @@ CHANGELOG = [
|
||||||
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen",
|
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen",
|
||||||
"Zuordnungen und Listen als JSON exportieren/importieren (Dev → Prod)",
|
"Zuordnungen und Listen als JSON exportieren/importieren (Dev → Prod)",
|
||||||
"Zuordnen: Mapping unabhängig vom Nährwert-Rebuild; eigener Katalogeintrag; Mengeneinheiten",
|
"Zuordnen: Mapping unabhängig vom Nährwert-Rebuild; eigener Katalogeintrag; Mengeneinheiten",
|
||||||
|
"Inline-Vorschläge (Haferflocken → Hafer Flocken), Bestätigen in der Zeile, Neu anlegen",
|
||||||
|
"Zuordnen: Suche und Bestätigen ohne Browser-Freeze (Index-Cache, Batch-Vorschläge, kein Seiten-Reload)",
|
||||||
|
"Zuordnen: Zeitraum 14 Tage / 4 Wochen / 90 Tage / Alle — zuerst aktuelle Tagebuchnamen",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
|
||||||
)
|
)
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
const timer = useRef(null)
|
const timer = useRef(null)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
const seqRef = useRef(0)
|
||||||
|
|
||||||
const extras = () => {
|
const extras = () => {
|
||||||
const g = parseFloat(String(gramsPerUnit).replace(',', '.'))
|
const g = parseFloat(String(gramsPerUnit).replace(',', '.'))
|
||||||
|
|
@ -50,19 +52,28 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
|
||||||
|
|
||||||
const runSearch = async (term) => {
|
const runSearch = async (term) => {
|
||||||
const query = (term || '').trim()
|
const query = (term || '').trim()
|
||||||
|
abortRef.current?.abort()
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
setHits([])
|
setHits([])
|
||||||
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const seq = ++seqRef.current
|
||||||
|
const ac = new AbortController()
|
||||||
|
abortRef.current = ac
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
setHits(await api.searchBlsFoods(query, 30))
|
const next = await api.searchBlsFoods(query, 20, ac.signal)
|
||||||
|
if (seq !== seqRef.current) return
|
||||||
|
setHits(next)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') return
|
||||||
|
if (seq !== seqRef.current) return
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
setHits([])
|
setHits([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (seq === seqRef.current) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,13 +86,20 @@ export default function FoodSearchModal({ title, initialQuery, quantityHint, onS
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKey)
|
window.removeEventListener('keydown', onKey)
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
|
abortRef.current?.abort()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onChange = (value) => {
|
const onChange = (value) => {
|
||||||
setQ(value)
|
setQ(value)
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
timer.current = setTimeout(() => runSearch(value), 250)
|
if (value.trim().length < 2) {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
setHits([])
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timer.current = setTimeout(() => runSearch(value), 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createManual = async () => {
|
const createManual = async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import FoodSearchModal from './FoodSearchModal'
|
import FoodSearchModal from './FoodSearchModal'
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ days: 14, label: '14 Tage' },
|
||||||
|
{ days: 28, label: '4 Wochen' },
|
||||||
|
{ days: 90, label: '90 Tage' },
|
||||||
|
{ days: 0, label: 'Alle' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDay(iso) {
|
||||||
|
const s = String(iso || '').slice(0, 10)
|
||||||
|
if (s.length < 10) return ''
|
||||||
|
const [y, m, d] = s.split('-')
|
||||||
|
return `${d}.${m}.${String(y).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
function suggestQuery(raw) {
|
function suggestQuery(raw) {
|
||||||
let s = (raw || '').replace(/^\s*[!]?\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim()
|
let s = (raw || '').replace(/^\s*[!]?\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim()
|
||||||
s = s.replace(/^!+/, '').trim()
|
s = s.replace(/^!+/, '').trim()
|
||||||
|
|
@ -40,31 +54,15 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
|
||||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
|
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '12px 16px' }}>
|
<div style={{ padding: '12px 16px' }}>
|
||||||
<input
|
<input className="form-input" style={{ width: '100%', textAlign: 'left' }} autoFocus placeholder="Rezeptname filtern" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
className="form-input"
|
|
||||||
style={{ width: '100%', textAlign: 'left' }}
|
|
||||||
autoFocus
|
|
||||||
placeholder="Rezeptname filtern"
|
|
||||||
value={q}
|
|
||||||
onChange={(e) => setQ(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
|
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
|
||||||
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept. Zuerst Listen-CSV importieren.</p>}
|
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept.</p>}
|
||||||
{filtered.map((r) => (
|
{filtered.map((r) => (
|
||||||
<button
|
<button key={r.id} type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} onClick={() => onPick(r.id)}>
|
||||||
key={r.id}
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-full"
|
|
||||||
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
|
|
||||||
onClick={() => onPick(r.id)}
|
|
||||||
>
|
|
||||||
<span>
|
<span>
|
||||||
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
|
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>{(r.ingredients || []).length} Zutaten</span>
|
||||||
{(r.ingredients || []).length} Zutaten
|
|
||||||
{r.portions ? ` · ${r.portions} Portionen` : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -74,7 +72,46 @@ function RecipePickModal({ recipes, sourceName, onPick, onClose }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NutritionFoodMap({ onChanged }) {
|
function InlineCreate({ defaultName, disabled, onCreate }) {
|
||||||
|
const [name, setName] = useState(defaultName || '')
|
||||||
|
const [kcal, setKcal] = useState('0')
|
||||||
|
const [protein, setProtein] = useState('0')
|
||||||
|
const [fat, setFat] = useState('0')
|
||||||
|
const [carbs, setCarbs] = useState('0')
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, padding: 10, background: 'var(--surface2)', borderRadius: 8 }}>
|
||||||
|
<p style={{ fontSize: 12, margin: '0 0 8px', color: 'var(--text2)' }}>Neuer Katalogeintrag (Werte pro 100 g, Salz z. B. alles 0)</p>
|
||||||
|
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
|
||||||
|
{[['kcal', kcal, setKcal], ['Protein', protein, setProtein], ['Fett', fat, setFat], ['KH', carbs, setCarbs]].map(([label, val, set]) => (
|
||||||
|
<label key={label} style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||||
|
{label}
|
||||||
|
<input className="form-input" type="number" min="0" step="0.1" style={{ width: '100%', textAlign: 'left', marginTop: 2 }} value={val} onChange={(e) => set(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
disabled={disabled || !name.trim()}
|
||||||
|
onClick={() => onCreate({
|
||||||
|
name_de: name.trim(),
|
||||||
|
macros_per_100g: {
|
||||||
|
kcal: parseFloat(String(kcal).replace(',', '.')) || 0,
|
||||||
|
protein_g: parseFloat(String(protein).replace(',', '.')) || 0,
|
||||||
|
fat_g: parseFloat(String(fat).replace(',', '.')) || 0,
|
||||||
|
carbs_g: parseFloat(String(carbs).replace(',', '.')) || 0,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Anlegen und zuordnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NutritionFoodMap({ onChanged, onMapped }) {
|
||||||
const [unmapped, setUnmapped] = useState([])
|
const [unmapped, setUnmapped] = useState([])
|
||||||
const [learned, setLearned] = useState([])
|
const [learned, setLearned] = useState([])
|
||||||
const [recipes, setRecipes] = useState([])
|
const [recipes, setRecipes] = useState([])
|
||||||
|
|
@ -83,22 +120,33 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
const [saving, setSaving] = useState(null)
|
const [saving, setSaving] = useState(null)
|
||||||
const [searchFor, setSearchFor] = useState(null)
|
const [searchFor, setSearchFor] = useState(null)
|
||||||
const [recipeFor, setRecipeFor] = useState(null)
|
const [recipeFor, setRecipeFor] = useState(null)
|
||||||
|
const [createFor, setCreateFor] = useState(null)
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [sinceDays, setSinceDays] = useState(28)
|
||||||
|
const [totalOpen, setTotalOpen] = useState(0)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [visible, setVisible] = useState(80)
|
||||||
|
const [showLearned, setShowLearned] = useState(false)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const listRef = useRef(null)
|
const listRef = useRef(null)
|
||||||
const bundleRef = useRef(null)
|
const bundleRef = useRef(null)
|
||||||
const loadGen = useRef(0)
|
const loadGen = useRef(0)
|
||||||
|
const askedSuggest = useRef(new Set())
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const gen = ++loadGen.current
|
const gen = ++loadGen.current
|
||||||
try {
|
try {
|
||||||
const [u, m, r] = await Promise.all([
|
const [u, m, r, counts] = await Promise.all([
|
||||||
api.listUnmappedFoods(),
|
api.listUnmappedFoods(sinceDays),
|
||||||
api.listMyFoodMappings(),
|
api.listMyFoodMappings(),
|
||||||
api.listNutritionRecipes().catch(() => []),
|
api.listNutritionRecipes().catch(() => []),
|
||||||
|
api.listUnmappedFoodCount(sinceDays).catch(() => null),
|
||||||
])
|
])
|
||||||
if (gen !== loadGen.current) return
|
if (gen !== loadGen.current) return
|
||||||
setUnmapped(u)
|
askedSuggest.current = new Set()
|
||||||
|
setUnmapped(Array.isArray(u) ? u : [])
|
||||||
|
setTotalOpen(Number(counts?.total) || (Array.isArray(u) ? u.length : 0))
|
||||||
setLearned(m)
|
setLearned(m)
|
||||||
setRecipes(Array.isArray(r) ? r : [])
|
setRecipes(Array.isArray(r) ? r : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -107,22 +155,75 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
useEffect(() => { load() }, [sinceDays])
|
||||||
|
|
||||||
const assign = async (sourceName, foodId, extras = {}) => {
|
const filtered = useMemo(() => {
|
||||||
|
const term = q.trim().toLowerCase()
|
||||||
|
return unmapped.filter((u) => {
|
||||||
|
if (filter === 'suggested' && !(u.suggestions || []).length) return false
|
||||||
|
if (filter === 'none' && (u.suggestions || []).length) return false
|
||||||
|
if (!term) return true
|
||||||
|
const hay = `${u.source_name_raw || ''} ${u.source_name_normalized || ''}`.toLowerCase()
|
||||||
|
return hay.includes(term)
|
||||||
|
})
|
||||||
|
}, [unmapped, filter, q])
|
||||||
|
|
||||||
|
const shown = filtered.slice(0, visible)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const need = []
|
||||||
|
const seen = new Set()
|
||||||
|
const take = (u) => {
|
||||||
|
const key = u.source_name_normalized
|
||||||
|
if (!key || askedSuggest.current.has(key) || seen.has(key)) return
|
||||||
|
seen.add(key)
|
||||||
|
need.push(u)
|
||||||
|
}
|
||||||
|
shown.forEach(take)
|
||||||
|
if (need.length < 80) {
|
||||||
|
for (const u of unmapped) {
|
||||||
|
take(u)
|
||||||
|
if (need.length >= 80) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!need.length) return
|
||||||
|
need.forEach((u) => askedSuggest.current.add(u.source_name_normalized))
|
||||||
|
const names = need.map((u) => u.source_name_raw).filter(Boolean)
|
||||||
|
if (!names.length) return
|
||||||
|
api.suggestFoodsBatch(names, 3).then((packed) => {
|
||||||
|
if (!packed || typeof packed !== 'object') return
|
||||||
|
setUnmapped((list) => list.map((u) => {
|
||||||
|
const hit = packed[u.source_name_raw]
|
||||||
|
return hit ? { ...u, ...hit } : u
|
||||||
|
}))
|
||||||
|
}).catch(() => {
|
||||||
|
need.forEach((u) => askedSuggest.current.delete(u.source_name_normalized))
|
||||||
|
})
|
||||||
|
}, [visible, filter, q, unmapped.length])
|
||||||
|
|
||||||
|
const assign = async (row, foodId, extras = {}, foodMeta = {}) => {
|
||||||
|
const sourceName = row.source_name_raw
|
||||||
setSaving(sourceName)
|
setSaving(sourceName)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.upsertMyFoodMapping({
|
const res = await api.upsertMyFoodMapping({
|
||||||
source_name: sourceName,
|
source_name: sourceName,
|
||||||
food_id: foodId,
|
food_id: foodId,
|
||||||
grams_per_unit: extras.grams_per_unit || null,
|
grams_per_unit: extras.grams_per_unit || null,
|
||||||
source_unit: extras.source_unit || null,
|
source_unit: extras.source_unit || null,
|
||||||
})
|
})
|
||||||
setSearchFor(null)
|
setSearchFor(null)
|
||||||
setNotice(`Zuordnung gespeichert: ${sourceName}`)
|
setCreateFor(null)
|
||||||
await load()
|
setUnmapped((list) => list.filter((x) => x.source_name_normalized !== row.source_name_normalized))
|
||||||
onChanged?.()
|
setLearned((list) => [{
|
||||||
|
id: res.mapping_id,
|
||||||
|
source_name_raw: sourceName,
|
||||||
|
food_name_de: res.food_name_de || foodMeta.name_de,
|
||||||
|
bls_code: res.bls_code || foodMeta.bls_code,
|
||||||
|
catalog_kind: res.catalog_kind || foodMeta.catalog_kind,
|
||||||
|
}, ...list])
|
||||||
|
setNotice(`Gespeichert: ${suggestQuery(sourceName) || sourceName}`)
|
||||||
|
onMapped?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -130,14 +231,26 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createAndAssign = async (row, body) => {
|
||||||
|
setSaving(row.source_name_raw)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const food = await api.createUserFood(body)
|
||||||
|
await assign(row, food.id, {}, { name_de: food.name_de, catalog_kind: food.catalog_kind })
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const applyRecipe = async (sourceName, recipeId) => {
|
const applyRecipe = async (sourceName, recipeId) => {
|
||||||
setSaving(sourceName)
|
setSaving(sourceName)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await api.applyNutritionRecipe(recipeId, sourceName)
|
await api.applyNutritionRecipe(recipeId, sourceName)
|
||||||
setRecipeFor(null)
|
setRecipeFor(null)
|
||||||
await load()
|
setUnmapped((list) => list.filter((x) => x.source_name_raw !== sourceName))
|
||||||
onChanged?.()
|
onMapped?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -149,6 +262,7 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
if (!confirm('Zuordnung wirklich löschen?')) return
|
if (!confirm('Zuordnung wirklich löschen?')) return
|
||||||
try {
|
try {
|
||||||
await api.deleteMyFoodMapping(id)
|
await api.deleteMyFoodMapping(id)
|
||||||
|
setLearned((list) => list.filter((x) => x.id !== id))
|
||||||
await load()
|
await load()
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -159,7 +273,6 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
const exportBundle = async () => {
|
const exportBundle = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setNotice(null)
|
|
||||||
try {
|
try {
|
||||||
await api.exportFoodKnowledge()
|
await api.exportFoodKnowledge()
|
||||||
setNotice('Zuordnungen und Listen als JSON heruntergeladen.')
|
setNotice('Zuordnungen und Listen als JSON heruntergeladen.')
|
||||||
|
|
@ -174,14 +287,12 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setNotice(null)
|
|
||||||
try {
|
try {
|
||||||
const res = await api.importFoodKnowledge(file)
|
const res = await api.importFoodKnowledge(file)
|
||||||
await load()
|
await load()
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
|
const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : ''
|
||||||
const lists = (res.inserted || 0) + (res.updated || 0)
|
setNotice(`${res.mappings || 0} Zuordnungen und ${(res.inserted || 0) + (res.updated || 0)} Listen übernommen${skip}.`)
|
||||||
setNotice(`${res.mappings || 0} Zuordnungen und ${lists} Listen übernommen${skip}.`)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -193,12 +304,11 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
setImporting(true)
|
setImporting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setNotice(null)
|
|
||||||
try {
|
try {
|
||||||
const res = await api.importFddbLists(file)
|
const res = await api.importFddbLists(file)
|
||||||
await load()
|
await load()
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen als Rezept verknüpft. Offene Zeilen sind jetzt die Zutaten.`)
|
setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen verknüpft.`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -206,114 +316,138 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const withSuggest = unmapped.filter((u) => (u.suggestions || []).length).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Lebensmittel zuordnen</div>
|
<div className="card-title">Lebensmittel zuordnen</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||||
Tippe auf „Im Katalog suchen“ — du suchst nach dem Namen, nicht nach einem Code.
|
Zuerst die letzten Wochen zuordnen. Ältere Namen (z. B. Getreide, das du nicht mehr isst) bleiben unter „Alle“ liegen und müssen nicht gemappt werden.
|
||||||
Eigene FDDB-Listen zuerst importieren, dann wird die Tagebuchzeile in Zutaten aufgelöst.
|
|
||||||
Für den Umzug nach Prod: Zuordnungen und Listen als JSON exportieren und dort wieder importieren.
|
|
||||||
</p>
|
</p>
|
||||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||||
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
||||||
|
|
||||||
<input
|
<input ref={listRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importLists(f) }} />
|
||||||
ref={listRef}
|
<button type="button" className="btn btn-secondary btn-full" disabled={importing} onClick={() => listRef.current?.click()}>
|
||||||
type="file"
|
|
||||||
accept=".csv,text/csv"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(e) => {
|
|
||||||
const f = e.target.files?.[0]
|
|
||||||
e.target.value = ''
|
|
||||||
if (f) importLists(f)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-full"
|
|
||||||
disabled={importing}
|
|
||||||
onClick={() => listRef.current?.click()}
|
|
||||||
>
|
|
||||||
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
|
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
|
||||||
</button>
|
</button>
|
||||||
{recipes.length > 0 && (
|
{recipes.length > 0 && <p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>}
|
||||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>
|
<input ref={bundleRef} type="file" accept=".json,application/json" style={{ display: 'none' }} onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ''; if (f) importBundle(f) }} />
|
||||||
)}
|
|
||||||
<input
|
|
||||||
ref={bundleRef}
|
|
||||||
type="file"
|
|
||||||
accept=".json,application/json"
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
onChange={(e) => {
|
|
||||||
const f = e.target.files?.[0]
|
|
||||||
e.target.value = ''
|
|
||||||
if (f) importBundle(f)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>Zuordnungen & Listen exportieren</button>
|
||||||
Zuordnungen & Listen exportieren
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => bundleRef.current?.click()}>Zuordnungen & Listen importieren</button>
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => bundleRef.current?.click()}>
|
|
||||||
Zuordnungen & Listen importieren
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Offen ({unmapped.length})</h3>
|
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>
|
||||||
{unmapped.length === 0 && <p className="muted">Keine offenen Bezeichner.</p>}
|
Offen ({filtered.length}{sinceDays && totalOpen > unmapped.length ? ` von ${totalOpen}` : filtered.length !== unmapped.length ? ` / ${unmapped.length}` : ''})
|
||||||
{unmapped.map((u) => {
|
</h3>
|
||||||
const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 8px' }}>{withSuggest} mit Vorschlag</p>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||||
|
{PERIODS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.days}
|
||||||
|
type="button"
|
||||||
|
className={sinceDays === p.days ? 'btn btn-primary' : 'btn btn-secondary'}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
onClick={() => { setSinceDays(p.days); setVisible(80); setQ('') }}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input className="form-input" style={{ width: '100%', textAlign: 'left', marginBottom: 8 }} placeholder="Offene Liste filtern…" value={q} onChange={(e) => { setQ(e.target.value); setVisible(80) }} />
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||||
|
{[['all', 'Alle Treffer'], ['suggested', 'Mit Vorschlag'], ['none', 'Ohne Vorschlag']].map(([id, label]) => (
|
||||||
|
<button key={id} type="button" className={filter === id ? 'btn btn-primary' : 'btn btn-secondary'} style={{ fontSize: 12 }} onClick={() => { setFilter(id); setVisible(80) }}>{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shown.length === 0 && (
|
||||||
|
<p className="muted">
|
||||||
|
{sinceDays && unmapped.length === 0 && totalOpen > 0
|
||||||
|
? `In diesem Zeitraum ist nichts Offen. ${totalOpen} ältere Namen liegen unter „Alle“ — die brauchst du nicht, wenn du sie nicht mehr isst.`
|
||||||
|
: 'Keine offenen Bezeichner in diesem Filter.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{shown.map((u) => {
|
||||||
|
const key = u.source_name_normalized
|
||||||
|
const suggestions = u.suggestions || []
|
||||||
|
const best = suggestions[0]
|
||||||
|
const busyRow = saving === u.source_name_raw
|
||||||
return (
|
return (
|
||||||
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
|
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
|
||||||
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'flex-start' }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
<div>
|
||||||
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
|
<div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
|
||||||
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen-Varianten` : ''}
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
{u.first_date ? ` · ${u.first_date} – ${u.last_date}` : ''}
|
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
|
||||||
|
{u.variant_count > 1 ? ` · ${u.variant_count} Mengen` : ''}
|
||||||
|
{u.last_date ? ` · zuletzt ${formatDay(u.last_date)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(u.ambiguous || suggestions.length > 1) && (
|
||||||
|
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 999, background: 'var(--surface2)', color: 'var(--danger)', whiteSpace: 'nowrap' }}>
|
||||||
|
mehrere möglich
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
|
||||||
<button
|
{best && (
|
||||||
type="button"
|
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
className="btn btn-primary"
|
<span style={{ fontSize: 13 }}>
|
||||||
disabled={saving === u.source_name_raw}
|
→ <strong>{best.name_de}</strong>
|
||||||
onClick={() => setSearchFor(u)}
|
<span style={{ color: 'var(--text3)' }}>{best.bls_code ? ` · ${best.bls_code}` : ' · manuell'}</span>
|
||||||
>
|
</span>
|
||||||
Im Katalog suchen
|
<button type="button" className="btn btn-primary" disabled={busyRow} onClick={() => assign(u, best.id, {}, best)}>
|
||||||
</button>
|
Bestätigen
|
||||||
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={saving === u.source_name_raw}
|
|
||||||
onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}
|
|
||||||
>
|
|
||||||
Als eigenes Rezept auflösen
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{suggestions.slice(1).map((s) => (
|
||||||
|
<div key={s.id} style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text2)' }}>oder {s.name_de}{s.bls_code ? ` · ${s.bls_code}` : ''}</span>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => assign(u, s.id, {}, s)}>Übernehmen</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!best && <p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>Kein Katalogvorschlag — selbst anlegen.</p>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setCreateFor(createFor === key ? null : key)}>
|
||||||
|
{createFor === key ? 'Anlegen schließen' : 'Neu anlegen'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setSearchFor(u)}>Mehr suchen</button>
|
||||||
|
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}>Als Rezept</button>
|
||||||
)}
|
)}
|
||||||
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
|
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
|
||||||
<button
|
<button type="button" className="btn btn-secondary" disabled={busyRow} onClick={() => setRecipeFor(u)}>Rezept wählen</button>
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={saving === u.source_name_raw}
|
|
||||||
onClick={() => setRecipeFor(u)}
|
|
||||||
>
|
|
||||||
Eigenes Rezept wählen
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{createFor === key && (
|
||||||
|
<InlineCreate defaultName={suggestQuery(u.source_name_raw) || u.source_name_raw} disabled={busyRow} onCreate={(body) => createAndAssign(u, body)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{visible < filtered.length && (
|
||||||
|
<button type="button" className="btn btn-secondary btn-full" style={{ marginTop: 8 }} onClick={() => setVisible((n) => n + 80)}>
|
||||||
|
Weitere {Math.min(80, filtered.length - visible)} zeigen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>Gelernt ({learned.length})</h3>
|
<h3 style={{ fontSize: 14, margin: '20px 0 8px' }}>
|
||||||
{learned.map((m) => (
|
Gelernt ({learned.length}){' '}
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => setShowLearned((v) => !v)}>
|
||||||
|
{showLearned ? 'Einklappen' : 'Anzeigen'}
|
||||||
|
</button>
|
||||||
|
</h3>
|
||||||
|
{showLearned && learned.map((m) => (
|
||||||
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
|
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', gap: 8, padding: '8px 0', borderTop: '1px solid var(--border)' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
|
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
|
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
|
||||||
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
||||||
{m.grams_per_unit ? ` · 1 ${m.source_unit || 'Einheit'} = ${m.grams_per_unit} g` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
||||||
|
|
@ -326,16 +460,11 @@ export default function NutritionFoodMap({ onChanged }) {
|
||||||
initialQuery={suggestQuery(searchFor.source_name_raw)}
|
initialQuery={suggestQuery(searchFor.source_name_raw)}
|
||||||
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
|
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
|
||||||
onClose={() => setSearchFor(null)}
|
onClose={() => setSearchFor(null)}
|
||||||
onSelect={(food, extras) => assign(searchFor.source_name_raw, food.id, extras)}
|
onSelect={(food, extras) => assign(searchFor, food.id, extras, food)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{recipeFor && (
|
{recipeFor && (
|
||||||
<RecipePickModal
|
<RecipePickModal recipes={recipes} sourceName={recipeFor.source_name_raw} onClose={() => setRecipeFor(null)} onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} />
|
||||||
recipes={recipes}
|
|
||||||
sourceName={recipeFor.source_name_raw}
|
|
||||||
onClose={() => setRecipeFor(null)}
|
|
||||||
onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -899,6 +899,7 @@ export default function NutritionPage() {
|
||||||
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
|
const [importHistoryKey, setImportHistoryKey] = useState(Date.now()) // BUG-004 fix
|
||||||
const [nutritionUsage, setNutritionUsage] = useState(null)
|
const [nutritionUsage, setNutritionUsage] = useState(null)
|
||||||
const [unmappedCount, setUnmappedCount] = useState(0)
|
const [unmappedCount, setUnmappedCount] = useState(0)
|
||||||
|
const [unmappedTotal, setUnmappedTotal] = useState(0)
|
||||||
|
|
||||||
const loadUsage = () => {
|
const loadUsage = () => {
|
||||||
nutritionApi.getFeatureUsage().then(features => {
|
nutritionApi.getFeatureUsage().then(features => {
|
||||||
|
|
@ -915,26 +916,38 @@ export default function NutritionPage() {
|
||||||
nutritionApi.nutritionWeekly(16),
|
nutritionApi.nutritionWeekly(16),
|
||||||
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries
|
||||||
nutritionApi.getActiveProfile(),
|
nutritionApi.getActiveProfile(),
|
||||||
nutritionApi.listUnmappedFoods().catch(() => []),
|
nutritionApi.listUnmappedFoodCount(28).catch(() => ({ count: 0, total: 0 })),
|
||||||
])
|
])
|
||||||
setCorr(Array.isArray(corr)?corr:[])
|
setCorr(Array.isArray(corr)?corr:[])
|
||||||
setWeekly(Array.isArray(wkly)?wkly:[])
|
setWeekly(Array.isArray(wkly)?wkly:[])
|
||||||
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
|
setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix
|
||||||
setProf(prof)
|
setProf(prof)
|
||||||
setUnmappedCount(Array.isArray(unmapped) ? unmapped.length : 0)
|
setUnmappedCount(Number(unmapped?.count) || 0)
|
||||||
|
setUnmappedTotal(Number(unmapped?.total) || Number(unmapped?.count) || 0)
|
||||||
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
setHasData(Array.isArray(corr) && corr.some(d=>d.kcal))
|
||||||
} catch(e) { console.error('load error:', e) }
|
} catch(e) { console.error('load error:', e) }
|
||||||
finally { setLoad(false) }
|
finally { setLoad(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshUnmappedCount = async () => {
|
||||||
|
try {
|
||||||
|
const d = await nutritionApi.listUnmappedFoodCount(28)
|
||||||
|
setUnmappedCount(Number(d?.count) || 0)
|
||||||
|
setUnmappedTotal(Number(d?.total) || Number(d?.count) || 0)
|
||||||
|
} catch { /* Banner bleibt auf letztem Stand */ }
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => { load(); loadUsage() }, [])
|
useEffect(() => { load(); loadUsage() }, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="capture-page">
|
<div className="capture-page">
|
||||||
<h1 className="page-title">Ernährung</h1>
|
<h1 className="page-title">Ernährung</h1>
|
||||||
{unmappedCount > 0 && (
|
{(unmappedCount > 0 || unmappedTotal > 0) && (
|
||||||
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
|
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
|
||||||
{unmappedCount} Lebensmittel noch ohne Katalog-Zuordnung.{' '}
|
{unmappedCount > 0
|
||||||
|
? `${unmappedCount} Lebensmittel der letzten 4 Wochen noch ohne Zuordnung${unmappedTotal > unmappedCount ? ` · ${unmappedTotal} insgesamt` : ''}.`
|
||||||
|
: `In den letzten 4 Wochen ist alles zugeordnet. ${unmappedTotal} ältere Namen ohne Zuordnung — nur nötig, wenn du sie wieder isst.`}
|
||||||
|
{' '}
|
||||||
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
|
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
|
||||||
Jetzt zuordnen
|
Jetzt zuordnen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -968,11 +981,16 @@ export default function NutritionPage() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{inputTab==='map' && <NutritionFoodMap onChanged={load} />}
|
{inputTab==='map' && (
|
||||||
|
<NutritionFoodMap
|
||||||
|
onMapped={refreshUnmappedCount}
|
||||||
|
onChanged={refreshUnmappedCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && <div className="empty-state"><div className="spinner"/></div>}
|
{loading && inputTab !== 'map' && <div className="empty-state"><div className="spinner"/></div>}
|
||||||
|
|
||||||
{!loading && !hasData && (
|
{!loading && !hasData && inputTab !== 'map' && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>Noch keine Ernährungsdaten</h3>
|
<h3>Noch keine Ernährungsdaten</h3>
|
||||||
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
|
<p>Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.</p>
|
||||||
|
|
@ -980,7 +998,7 @@ export default function NutritionPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Analysis Section */}
|
{/* Analysis Section */}
|
||||||
{!loading && hasData && (
|
{!loading && hasData && inputTab !== 'map' && (
|
||||||
<>
|
<>
|
||||||
<OverviewCards data={corrData}/>
|
<OverviewCards data={corrData}/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -240,7 +240,8 @@ export const api = {
|
||||||
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
||||||
},
|
},
|
||||||
listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'),
|
listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'),
|
||||||
listUnmappedFoods: () => req('/nutrition/unmapped'),
|
listUnmappedFoods: (sinceDays=0) => req(`/nutrition/unmapped?since_days=${sinceDays || 0}`),
|
||||||
|
listUnmappedFoodCount: (sinceDays=28) => req(`/nutrition/unmapped?count_only=true&since_days=${sinceDays || 0}`),
|
||||||
listNutritionRecipes: () => req('/nutrition/recipes'),
|
listNutritionRecipes: () => req('/nutrition/recipes'),
|
||||||
importFddbLists: async (file) => {
|
importFddbLists: async (file) => {
|
||||||
const fd = new FormData(); fd.append('file', file)
|
const fd = new FormData(); fd.append('file', file)
|
||||||
|
|
@ -273,7 +274,8 @@ export const api = {
|
||||||
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
|
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
|
||||||
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
|
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
|
||||||
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
|
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
|
||||||
searchBlsFoods: (q, limit=20) => req(`/bls/foods?q=${encodeURIComponent(q||'')}&limit=${limit}`),
|
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)),
|
createUserFood: (d) => req('/bls/foods/manual', json(d)),
|
||||||
listMyFoodMappings: () => req('/bls/mappings'),
|
listMyFoodMappings: () => req('/bls/mappings'),
|
||||||
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
|
upsertMyFoodMapping: (d) => req('/bls/mappings', json(d)),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user