Compare commits

..

1 Commits

Author SHA1 Message Date
95453d548a fix: Listen in der Tagesuebersicht und Katalog-Makros uebernehmen
All checks were successful
Deploy Development / deploy (push) Successful in 1m13s
Build Test / pytest-backend (push) Successful in 6s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
Tagebuchzeilen mit Liste gelten als zugeordnet. Berechnete Zutaten-Makros lassen sich am Tag in nutrition_log schreiben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 13:31:30 +02:00
10 changed files with 221 additions and 28 deletions

View File

@ -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.

View File

@ -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).

View File

@ -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]:

View File

@ -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

View File

@ -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"

View File

@ -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,

View File

@ -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 = [
[],

View File

@ -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)

View File

@ -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))
} catch (e) {
setError(e.message)
return
}
try {
setError(null)
await load()
setOpen(true)
} catch (e) {
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 (
@ -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) => (
<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'}
</li>
))}
</ul>
<>
<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) => {
const asg = itemAssignment(it)
return (
<li key={it.id}>
{it.source_name_raw}
{it.quantity_raw ? ` · ${it.quantity_raw}` : ''}
{' '}
<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>
)}

View File

@ -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})),