Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95453d548a |
|
|
@ -48,6 +48,8 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a
|
||||||
|
|
||||||
Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute (Bezeichnung änderbar, Schlüssel fest). In die Makro-Haupttabelle (`nutrition_log`) fließen nur ENERCC→kcal, PROT625→protein_g, FAT→fat_g, CHO→carbs_g (Wert je 100 g × gegessene g / 100). Andere Stoffe gehen nach `nutrition_daily_nutrients`.
|
Eigene Einträge des Nutzers sind **persönlich** (`manual_user`). Der Admin legt **zentrale** Einträge für alle an (`manual_admin`). Beide speichern **dieselben Stoffwerte** wie BLS-Lebensmittel (`food_attribute_values`, immer pro 100 g). Beim Anlegen: Makros plus Suche nach weiteren Stoffen (EPA, DHA, Omega-3). Etikett „pro 8 ml“ → Portionsgröße in g angeben, Umrechnung auf 100 g erfolgt serverseitig. Einheit des Stoffs beachten (oft **g**, Etikett in mg: 1100 mg = 1,1 g). Fehlt ein Stoff im Katalog: Nutzer kann ihn selbst anlegen oder Admin → Stoffe & Attribute (Bezeichnung änderbar, Schlüssel fest). In die Makro-Haupttabelle (`nutrition_log`) fließen nur ENERCC→kcal, PROT625→protein_g, FAT→fat_g, CHO→carbs_g (Wert je 100 g × gegessene g / 100). Andere Stoffe gehen nach `nutrition_daily_nutrients`.
|
||||||
|
|
||||||
|
**Listen am Tag:** Eine Mitai-Liste (z. B. Quark-Skyr-Blaubeeren) ist zugeordnet, sobald die Tagebuchzeile `recipe_id` hat — nicht erst bei einem einzelnen `food_id`. In der Tagesübersicht erscheint „Liste: …“. Sind alle Zutaten gemappt, lassen sich die berechneten Katalog-Makros (Summe der Zutaten, skaliert auf die gegessene Menge) per Knopf in `nutrition_log` übernehmen.
|
||||||
|
|
||||||
**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar.
|
**Nachträglich ändern:** Unter Ernährung → Tab **Lebensmittel** (persönliche Liste + zentrale Admin-Einträge, Suche). Persönliche Einträge legt und ändert der Owner. Zentrale Einträge liest jeder; **ändert nur ein Admin** (gleiche Liste, Zeile oder Bearbeiten). Beim Zuordnen gilt die Reihenfolge persönlich → zentral → BLS. Globale Namens-Mappings (Admin) gelten für alle, dürfen aber nicht auf ein privates Nutzer-Lebensmittel zeigen. Offizielle BLS-Einträge bleiben fest. Beim Speichern gelten die Felder als Werte je 100 g, außer die Portionsgröße wird bewusst auf die Etikett-Portion gesetzt. Betroffene Tagebuch-Tage werden im Hintergrund neu berechnet. Admin → Lebensmittelkatalog → Reiter **Manuell** zeigt **Zentral** und **Persönlich** getrennt; beide sind dort änderbar.
|
||||||
|
|
||||||
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.
|
Weitere Quellen (USDA, Schweizer Nährwertdatenbank) kommen später als zusätzliche Katalogherkunft, nicht als zweite Wertetabelle.
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,5 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy /
|
||||||
- `GET /nutrition/ingredient-names` — Distinct-Zutatennamen aller Listen inkl. Mapping-Status. Listendialog mappt manuell (`upsertMyFoodMapping`) an der Zeile.
|
- `GET /nutrition/ingredient-names` — Distinct-Zutatennamen aller Listen inkl. Mapping-Status. Listendialog mappt manuell (`upsertMyFoodMapping`) an der Zeile.
|
||||||
- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen; Rebuild läuft nach der API-Antwort im Hintergrund (UI bleibt bedienbar)
|
- 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 /nutrition/items` liefert `recipe_name_de` + `recipe_complete`. `GET /nutrition/days/{date}/macros` vergleicht bestehende vs. Katalog-Tagesmakros; `POST …/apply-catalog-macros` schreibt die berechnete Summe nach `nutrition_log`. Listen-Save verknüpft passende Tagebuchzeilen (`items_linked`).
|
||||||
- `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).
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ LEADING_QTY_RE = re.compile(
|
||||||
r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-–]*",
|
r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-–]*",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
TRAILING_QTY_RE = re.compile(
|
||||||
|
r"[\s,.:\-–]+\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l)\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
MULTISPACE_RE = re.compile(r"\s+")
|
MULTISPACE_RE = re.compile(r"\s+")
|
||||||
DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)")
|
DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)")
|
||||||
|
|
||||||
|
|
@ -27,8 +31,10 @@ def normalize_food_name(raw: str | None) -> str:
|
||||||
s = strip_leading_quantity(raw)
|
s = strip_leading_quantity(raw)
|
||||||
if not s:
|
if not s:
|
||||||
return ""
|
return ""
|
||||||
|
s = TRAILING_QTY_RE.sub("", s)
|
||||||
|
s = s.replace("-", " ")
|
||||||
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
|
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
|
||||||
return s.lower()
|
return MULTISPACE_RE.sub(" ", s).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
def item_name_keys(raw: str | None, stored_norm: str | None = None) -> set[str]:
|
def item_name_keys(raw: str | None, stored_norm: str | None = None) -> set[str]:
|
||||||
|
|
|
||||||
|
|
@ -326,10 +326,12 @@ def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None
|
||||||
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i,
|
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
link_recipes_to_items(cur, profile_id)
|
linked, dates = link_recipes_to_items(cur, profile_id)
|
||||||
saved = get_recipe(cur, profile_id, rid)
|
saved = get_recipe(cur, profile_id, rid)
|
||||||
if not saved:
|
if not saved:
|
||||||
raise ValueError("Rezept konnte nicht gelesen werden")
|
raise ValueError("Rezept konnte nicht gelesen werden")
|
||||||
|
saved["items_linked"] = linked
|
||||||
|
saved["dates_linked"] = dates
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -446,6 +446,65 @@ def _touch_item_counts(cur, profile_id: str, day: str, sums: dict) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def day_macro_preview(cur, profile_id: str, day: date | str) -> dict[str, Any]:
|
||||||
|
sums = compute_day_macro_sums(cur, profile_id, day)
|
||||||
|
catalog = sums.get("catalog")
|
||||||
|
existing = sums.get("existing")
|
||||||
|
mapped = int(sums.get("mapped_item_count") or 0)
|
||||||
|
return {
|
||||||
|
"date": str(day)[:10],
|
||||||
|
"existing": existing,
|
||||||
|
"catalog": catalog,
|
||||||
|
"fddb": sums.get("fddb"),
|
||||||
|
"mapped_item_count": mapped,
|
||||||
|
"unmapped_item_count": int(sums.get("unmapped_item_count") or 0),
|
||||||
|
"catalog_origin": sums.get("catalog_origin"),
|
||||||
|
"can_apply": bool(catalog) and mapped > 0,
|
||||||
|
"differs": bool(existing and catalog and macros_differ(existing, catalog)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_day_catalog_macros(cur, profile_id: str, day: date | str) -> dict[str, Any]:
|
||||||
|
preview = day_macro_preview(cur, profile_id, day)
|
||||||
|
if not preview["can_apply"]:
|
||||||
|
raise ValueError("Keine aus Zuordnungen berechenbaren Tagesmakros")
|
||||||
|
cur.execute(
|
||||||
|
"SELECT source FROM nutrition_log WHERE profile_id = %s AND date = %s",
|
||||||
|
(profile_id, day),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
source = (row.get("source") if row else None) or "csv"
|
||||||
|
apply_nutrition_day_macros(
|
||||||
|
cur,
|
||||||
|
profile_id,
|
||||||
|
day,
|
||||||
|
preview["catalog"],
|
||||||
|
macro_origin=preview.get("catalog_origin") or "bls",
|
||||||
|
source=source,
|
||||||
|
confirm=True,
|
||||||
|
)
|
||||||
|
rebuild_daily_nutrients(cur, profile_id, day)
|
||||||
|
preview = day_macro_preview(cur, profile_id, day)
|
||||||
|
preview["applied"] = True
|
||||||
|
return preview
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_nutrition_item(cur, profile_id: str, row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
item = dict(row)
|
||||||
|
item["id"] = str(item["id"])
|
||||||
|
if item.get("food_id"):
|
||||||
|
item["food_id"] = str(item["food_id"])
|
||||||
|
if item.get("recipe_id"):
|
||||||
|
item["recipe_id"] = str(item["recipe_id"])
|
||||||
|
from data_layer.food_recipes import catalog_macros_for_recipe
|
||||||
|
item["recipe_complete"] = catalog_macros_for_recipe(
|
||||||
|
cur, profile_id, item["recipe_id"], item.get("quantity_g")
|
||||||
|
) is not None
|
||||||
|
else:
|
||||||
|
item["recipe_complete"] = False
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]:
|
def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]:
|
||||||
if choice == "existing":
|
if choice == "existing":
|
||||||
return sums["existing"], "user_confirmed"
|
return sums["existing"], "user_confirmed"
|
||||||
|
|
|
||||||
|
|
@ -339,9 +339,11 @@ def list_nutrition_items(
|
||||||
if date:
|
if date:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind,
|
||||||
|
r.name_raw AS recipe_name_de
|
||||||
FROM nutrition_items i
|
FROM nutrition_items i
|
||||||
LEFT JOIN food_catalog f ON f.id = i.food_id
|
LEFT JOIN food_catalog f ON f.id = i.food_id
|
||||||
|
LEFT JOIN food_recipes r ON r.id = i.recipe_id
|
||||||
WHERE i.profile_id=%s AND i.date=%s
|
WHERE i.profile_id=%s AND i.date=%s
|
||||||
ORDER BY i.logged_at NULLS LAST, i.source_name_raw
|
ORDER BY i.logged_at NULLS LAST, i.source_name_raw
|
||||||
""",
|
""",
|
||||||
|
|
@ -350,16 +352,19 @@ def list_nutrition_items(
|
||||||
else:
|
else:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind
|
SELECT i.*, f.name_de AS food_name_de, f.bls_code, f.catalog_kind,
|
||||||
|
r.name_raw AS recipe_name_de
|
||||||
FROM nutrition_items i
|
FROM nutrition_items i
|
||||||
LEFT JOIN food_catalog f ON f.id = i.food_id
|
LEFT JOIN food_catalog f ON f.id = i.food_id
|
||||||
|
LEFT JOIN food_recipes r ON r.id = i.recipe_id
|
||||||
WHERE i.profile_id=%s
|
WHERE i.profile_id=%s
|
||||||
ORDER BY i.date DESC, i.logged_at NULLS LAST
|
ORDER BY i.date DESC, i.logged_at NULLS LAST
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
""",
|
""",
|
||||||
(pid, min(limit, 500)),
|
(pid, min(limit, 500)),
|
||||||
)
|
)
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
from data_layer.nutrition_items import annotate_nutrition_item
|
||||||
|
return [annotate_nutrition_item(cur, pid, r2d(r)) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/unmapped")
|
@router.get("/unmapped")
|
||||||
|
|
@ -422,10 +427,13 @@ def create_food_recipe(
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
from data_layer.food_recipes import save_recipe
|
from data_layer.food_recipes import save_recipe
|
||||||
|
from data_layer.nutrition_items import schedule_daily_nutrient_rebuild
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
try:
|
try:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
return save_recipe(get_cursor(conn), pid, body.model_dump())
|
saved = save_recipe(get_cursor(conn), pid, body.model_dump())
|
||||||
|
schedule_daily_nutrient_rebuild(pid, saved.get("dates_linked") or [], "Liste angelegt")
|
||||||
|
return saved
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e)) from e
|
raise HTTPException(400, str(e)) from e
|
||||||
|
|
||||||
|
|
@ -438,10 +446,13 @@ def update_food_recipe(
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
from data_layer.food_recipes import save_recipe
|
from data_layer.food_recipes import save_recipe
|
||||||
|
from data_layer.nutrition_items import schedule_daily_nutrient_rebuild
|
||||||
pid = get_pid(x_profile_id)
|
pid = get_pid(x_profile_id)
|
||||||
try:
|
try:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
return save_recipe(get_cursor(conn), pid, body.model_dump(), recipe_id)
|
saved = save_recipe(get_cursor(conn), pid, body.model_dump(), recipe_id)
|
||||||
|
schedule_daily_nutrient_rebuild(pid, saved.get("dates_linked") or [], "Liste geändert")
|
||||||
|
return saved
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(404, "Rezept nicht gefunden") from None
|
raise HTTPException(404, "Rezept nicht gefunden") from None
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
@ -612,6 +623,35 @@ def resolve_import_conflicts(
|
||||||
return {"applied": applied}
|
return {"applied": applied}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/days/{day}/macros")
|
||||||
|
def get_day_macros(
|
||||||
|
day: str,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
from data_layer.nutrition_items import day_macro_preview
|
||||||
|
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
return day_macro_preview(get_cursor(conn), pid, day)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/days/{day}/apply-catalog-macros")
|
||||||
|
def apply_catalog_macros_for_day(
|
||||||
|
day: str,
|
||||||
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
from data_layer.nutrition_items import apply_day_catalog_macros
|
||||||
|
|
||||||
|
pid = get_pid(x_profile_id)
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
return apply_day_catalog_macros(get_cursor(conn), pid, day)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
@router.put("/days/{day}/mark")
|
@router.put("/days/{day}/mark")
|
||||||
def upsert_day_mark(
|
def upsert_day_mark(
|
||||||
day: str,
|
day: str,
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,11 @@ def test_list_name_matches_despite_quantity_prefix():
|
||||||
assert not names_match_list("Haferflocken", "haferflocken", "müsli mix")
|
assert not names_match_list("Haferflocken", "haferflocken", "müsli mix")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_name_matches_hyphen_and_trailing_qty():
|
||||||
|
assert names_match_list("Quark-Skyr Blaubeeren", "quark-skyr blaubeeren", "quark skyr blaubeeren")
|
||||||
|
assert names_match_list("Quark skyr Blaubeeren 250 g", None, "Quark skyr Blaubeeren")
|
||||||
|
|
||||||
|
|
||||||
def test_unmapped_meta_includes_recent_ingredients():
|
def test_unmapped_meta_includes_recent_ingredients():
|
||||||
batches = [
|
batches = [
|
||||||
[],
|
[],
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ 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.3.7", # Sichtbarkeit persönlich vs. zentral im Mapping
|
"nutrition": "1.3.8", # Listen in Tagesübersicht; Katalog-Makros auf den Tag übernehmen
|
||||||
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
|
"tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet)
|
||||||
"bls": "1.0.13", # Stoffe gruppiert und sortiert (BLS-Nährstoffgruppen)
|
"bls": "1.0.13", # Stoffe gruppiert und sortiert (BLS-Nährstoffgruppen)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -197,25 +197,63 @@ function EntryForm({ onSaved }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data Tab (Editable Entry List) ───────────────────────────────────────────
|
// ── Data Tab (Editable Entry List) ───────────────────────────────────────────
|
||||||
function DayItems({ date }) {
|
function itemAssignment(it) {
|
||||||
|
if (it.food_name_de) return { ok: true, text: `→ ${it.food_name_de}` }
|
||||||
|
if (it.recipe_name_de || it.recipe_id) {
|
||||||
|
const name = it.recipe_name_de || 'Liste'
|
||||||
|
if (it.recipe_complete) return { ok: true, text: `→ Liste: ${name}` }
|
||||||
|
return { ok: false, text: `→ Liste: ${name} (Zutaten noch offen)` }
|
||||||
|
}
|
||||||
|
return { ok: false, text: '· noch nicht zugeordnet' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMacros(m) {
|
||||||
|
if (!m) return '—'
|
||||||
|
return `${Math.round(m.kcal || 0)} kcal · P ${Math.round(m.protein_g || 0)}g · F ${Math.round(m.fat_g || 0)}g · KH ${Math.round(m.carbs_g || 0)}g`
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayItems({ date, onChanged }) {
|
||||||
const [items, setItems] = useState(null)
|
const [items, setItems] = useState(null)
|
||||||
|
const [macros, setMacros] = useState(null)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const [nextItems, nextMacros] = await Promise.all([
|
||||||
|
nutritionApi.listNutritionItems(date),
|
||||||
|
nutritionApi.getNutritionDayMacros(date).catch(() => null),
|
||||||
|
])
|
||||||
|
setItems(nextItems)
|
||||||
|
setMacros(nextMacros)
|
||||||
|
}
|
||||||
|
|
||||||
const toggle = async () => {
|
const toggle = async () => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!items) {
|
try {
|
||||||
try {
|
setError(null)
|
||||||
setItems(await nutritionApi.listNutritionItems(date))
|
await load()
|
||||||
} catch (e) {
|
setOpen(true)
|
||||||
setError(e.message)
|
} catch (e) {
|
||||||
return
|
setError(e.message)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyCatalog = async () => {
|
||||||
|
setApplying(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const next = await nutritionApi.applyNutritionDayCatalogMacros(date)
|
||||||
|
setMacros(next)
|
||||||
|
onChanged?.()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setApplying(false)
|
||||||
}
|
}
|
||||||
setOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -225,16 +263,54 @@ function DayItems({ date }) {
|
||||||
</button>
|
</button>
|
||||||
{error && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{error}</p>}
|
{error && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{error}</p>}
|
||||||
{open && Array.isArray(items) && (
|
{open && Array.isArray(items) && (
|
||||||
<ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 13, color: 'var(--text1)' }}>
|
<>
|
||||||
{items.length === 0 && <li style={{ color: 'var(--text3)' }}>Keine Zeilen für diesen Tag</li>}
|
<ul style={{ margin: '8px 0 0', paddingLeft: 18, fontSize: 13, color: 'var(--text1)' }}>
|
||||||
{items.map((it) => (
|
{items.length === 0 && <li style={{ color: 'var(--text3)' }}>Keine Zeilen für diesen Tag</li>}
|
||||||
<li key={it.id}>
|
{items.map((it) => {
|
||||||
{it.source_name_raw}
|
const asg = itemAssignment(it)
|
||||||
{it.quantity_raw ? ` · ${it.quantity_raw}` : ''}
|
return (
|
||||||
{it.food_name_de ? ` → ${it.food_name_de}` : ' · noch nicht zugeordnet'}
|
<li key={it.id}>
|
||||||
</li>
|
{it.source_name_raw}
|
||||||
))}
|
{it.quantity_raw ? ` · ${it.quantity_raw}` : ''}
|
||||||
</ul>
|
{' '}
|
||||||
|
<span style={{ color: asg.ok ? 'var(--accent-dark)' : 'var(--text3)' }}>{asg.text}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{macros && (
|
||||||
|
<div style={{ marginTop: 10, padding: '8px 10px', background: 'var(--surface2)', borderRadius: 8, fontSize: 12 }}>
|
||||||
|
<div>Aktuell in der Tagesübersicht: {formatMacros(macros.existing)}</div>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
Aus Zuordnung berechnet
|
||||||
|
{macros.catalog_origin ? ` (${macros.catalog_origin})` : ''}:
|
||||||
|
{' '}{formatMacros(macros.catalog)}
|
||||||
|
{macros.unmapped_item_count
|
||||||
|
? ` · ${macros.unmapped_item_count} Zeile${macros.unmapped_item_count === 1 ? '' : 'n'} noch ohne vollständige Zuordnung (dort FDDB)`
|
||||||
|
: ''}
|
||||||
|
</div>
|
||||||
|
{macros.can_apply && macros.differs && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ marginTop: 8, fontSize: 12 }}
|
||||||
|
disabled={applying}
|
||||||
|
onClick={applyCatalog}
|
||||||
|
>
|
||||||
|
{applying ? 'Übernehme…' : 'Berechnete Makros in den Tag übernehmen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{macros.can_apply && !macros.differs && (
|
||||||
|
<div style={{ marginTop: 6, color: 'var(--accent-dark)' }}>Tagesmakros entsprechen bereits der Zuordnung.</div>
|
||||||
|
)}
|
||||||
|
{!macros.can_apply && (
|
||||||
|
<div style={{ marginTop: 6, color: 'var(--text3)' }}>
|
||||||
|
Noch keine berechenbaren Katalog-Makros. Liste verknüpfen und alle Zutaten zuordnen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -376,7 +452,7 @@ function DataTab({ entries, onUpdate }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{e.item_count > 0 ? (
|
{e.item_count > 0 ? (
|
||||||
<DayItems date={e.date} />
|
<DayItems date={e.date} onChanged={onUpdate} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{fontSize:12, color:'var(--text3)', marginTop:6}}>Keine einzelnen Lebensmittelzeilen</div>
|
<div style={{fontSize:12, color:'var(--text3)', marginTop:6}}>Keine einzelnen Lebensmittelzeilen</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,8 @@ export const api = {
|
||||||
return readJsonResponse(r)
|
return readJsonResponse(r)
|
||||||
},
|
},
|
||||||
listNutritionMarks: () => req('/nutrition/marks'),
|
listNutritionMarks: () => req('/nutrition/marks'),
|
||||||
|
getNutritionDayMacros: (date) => req(`/nutrition/days/${encodeURIComponent(date)}/macros`),
|
||||||
|
applyNutritionDayCatalogMacros: (date) => req(`/nutrition/days/${encodeURIComponent(date)}/apply-catalog-macros`, { method: 'POST' }),
|
||||||
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})),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user