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`.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
- 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**)
|
||||
- `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).
|
||||
|
|
|
|||
|
|
@ -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,.:\-–]*",
|
||||
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+")
|
||||
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)
|
||||
if not s:
|
||||
return ""
|
||||
s = TRAILING_QTY_RE.sub("", s)
|
||||
s = s.replace("-", " ")
|
||||
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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
link_recipes_to_items(cur, profile_id)
|
||||
linked, dates = link_recipes_to_items(cur, profile_id)
|
||||
saved = get_recipe(cur, profile_id, rid)
|
||||
if not saved:
|
||||
raise ValueError("Rezept konnte nicht gelesen werden")
|
||||
saved["items_linked"] = linked
|
||||
saved["dates_linked"] = dates
|
||||
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]:
|
||||
if choice == "existing":
|
||||
return sums["existing"], "user_confirmed"
|
||||
|
|
|
|||
|
|
@ -339,9 +339,11 @@ def list_nutrition_items(
|
|||
if date:
|
||||
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
|
||||
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
|
||||
ORDER BY i.logged_at NULLS LAST, i.source_name_raw
|
||||
""",
|
||||
|
|
@ -350,16 +352,19 @@ def list_nutrition_items(
|
|||
else:
|
||||
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
|
||||
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
|
||||
ORDER BY i.date DESC, i.logged_at NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(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")
|
||||
|
|
@ -422,10 +427,13 @@ def create_food_recipe(
|
|||
session: dict = Depends(require_auth),
|
||||
):
|
||||
from data_layer.food_recipes import save_recipe
|
||||
from data_layer.nutrition_items import schedule_daily_nutrient_rebuild
|
||||
pid = get_pid(x_profile_id)
|
||||
try:
|
||||
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:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
|
@ -438,10 +446,13 @@ def update_food_recipe(
|
|||
session: dict = Depends(require_auth),
|
||||
):
|
||||
from data_layer.food_recipes import save_recipe
|
||||
from data_layer.nutrition_items import schedule_daily_nutrient_rebuild
|
||||
pid = get_pid(x_profile_id)
|
||||
try:
|
||||
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:
|
||||
raise HTTPException(404, "Rezept nicht gefunden") from None
|
||||
except ValueError as e:
|
||||
|
|
@ -612,6 +623,35 @@ def resolve_import_conflicts(
|
|||
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")
|
||||
def upsert_day_mark(
|
||||
day: str,
|
||||
|
|
|
|||
|
|
@ -137,6 +137,11 @@ def test_list_name_matches_despite_quantity_prefix():
|
|||
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():
|
||||
batches = [
|
||||
[],
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ MODULE_VERSIONS = {
|
|||
"circumference": "1.0.1",
|
||||
"caliper": "1.0.1",
|
||||
"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)
|
||||
"bls": "1.0.13", # Stoffe gruppiert und sortiert (BLS-Nährstoffgruppen)
|
||||
|
||||
|
|
|
|||
|
|
@ -197,25 +197,63 @@ function EntryForm({ onSaved }) {
|
|||
}
|
||||
|
||||
// ── 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 [macros, setMacros] = useState(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
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 () => {
|
||||
if (open) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
if (!items) {
|
||||
try {
|
||||
setItems(await nutritionApi.listNutritionItems(date))
|
||||
setError(null)
|
||||
await load()
|
||||
setOpen(true)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
setOpen(true)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -225,16 +263,54 @@ function DayItems({ date }) {
|
|||
</button>
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: 12, margin: '6px 0 0' }}>{error}</p>}
|
||||
{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>}
|
||||
{items.map((it) => (
|
||||
{items.map((it) => {
|
||||
const asg = itemAssignment(it)
|
||||
return (
|
||||
<li key={it.id}>
|
||||
{it.source_name_raw}
|
||||
{it.quantity_raw ? ` · ${it.quantity_raw}` : ''}
|
||||
{it.food_name_de ? ` → ${it.food_name_de}` : ' · noch nicht zugeordnet'}
|
||||
{' '}
|
||||
<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>
|
||||
)
|
||||
|
|
@ -376,7 +452,7 @@ function DataTab({ entries, onUpdate }) {
|
|||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -281,6 +281,8 @@ export const api = {
|
|||
return readJsonResponse(r)
|
||||
},
|
||||
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)),
|
||||
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),
|
||||
resolveNutritionConflicts: (decisions) => req('/nutrition/import-conflicts/resolve', json({decisions})),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user