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>
This commit is contained in:
Lars 2026-09-14 13:31:30 +02:00
parent 44e3e216ab
commit 95453d548a
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`. 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.

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

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,.:\-–]*", 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]:

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

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

View File

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

View File

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

View File

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

View File

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

View File

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