diff --git a/nouri/__init__.py b/nouri/__init__.py index 45bd41e..b7a17cf 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -19,10 +19,21 @@ from .constants import ( DEFAULT_CATEGORIES, ENERGY_DENSITY_LABELS, ENERGY_DENSITY_OPTIONS, + FOOD_ROLE_DESCRIPTIONS, + FOOD_ROLE_LABELS, + FOOD_ROLE_OPTIONS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, + MEAL_STYLE_LABELS, + MEAL_STYLE_OPTIONS, + MEAL_TYPE_LABELS, + MEAL_TYPE_OPTIONS, NOTIFICATION_CHANNEL_OPTIONS, + PROTEIN_PREFERENCE_LABELS, + PROTEIN_PREFERENCE_OPTIONS, ROLE_LABELS, + SUGGESTION_PRIORITY_LABELS, + SUGGESTION_PRIORITY_OPTIONS, SUGGESTION_STYLE_LABELS, SUGGESTION_STYLE_OPTIONS, VISIBILITY_DESCRIPTIONS, @@ -140,11 +151,22 @@ def create_app() -> Flask: "builder_labels": BUILDER_LABELS, "builder_descriptions": BUILDER_DESCRIPTIONS, "builder_options": BUILDER_OPTIONS, + "food_role_labels": FOOD_ROLE_LABELS, + "food_role_descriptions": FOOD_ROLE_DESCRIPTIONS, + "food_role_options": FOOD_ROLE_OPTIONS, + "suggestion_priority_labels": SUGGESTION_PRIORITY_LABELS, + "suggestion_priority_options": SUGGESTION_PRIORITY_OPTIONS, "daypart_suggestions": DAYPARTS, "energy_density_options": ENERGY_DENSITY_OPTIONS, "energy_density_labels": ENERGY_DENSITY_LABELS, + "meal_type_options": MEAL_TYPE_OPTIONS, + "meal_type_labels": MEAL_TYPE_LABELS, + "meal_style_options": MEAL_STYLE_OPTIONS, + "meal_style_labels": MEAL_STYLE_LABELS, "suggestion_style_options": SUGGESTION_STYLE_OPTIONS, "suggestion_style_labels": SUGGESTION_STYLE_LABELS, + "protein_preference_options": PROTEIN_PREFERENCE_OPTIONS, + "protein_preference_labels": PROTEIN_PREFERENCE_LABELS, "visibility_labels": VISIBILITY_LABELS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS, "role_labels": ROLE_LABELS, diff --git a/nouri/constants.py b/nouri/constants.py index 46684bb..ba34523 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -7,6 +7,15 @@ DAYPARTS = [ {"slug": "late-snack", "name": "Später Snack", "sort_order": 60}, ] +DAYPART_SLUG_TO_MEAL_TYPE = { + "breakfast": "breakfast", + "morning-snack": "snack", + "lunch": "lunch", + "afternoon-snack": "snack", + "dinner": "dinner", + "late-snack": "snack", +} + DEFAULT_CATEGORIES = [ "Kohlenhydrate", "Milchprodukt", @@ -58,6 +67,42 @@ BUILDER_DESCRIPTIONS = { BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()] +FOOD_ROLE_LABELS = { + "main": "Hauptbaustein", + "base": "Basis", + "complement": "Ergänzung", + "topping": "Topping", + "cooking": "Kochzutat", + "snack": "Snack-Baustein", + "solo": "Schnelle Einzelmahlzeit", +} + +FOOD_ROLE_DESCRIPTIONS = { + "main": "Kann einen Teller oder eine Hauptmahlzeit deutlich tragen.", + "base": "Passt gut als Grundlage und lässt sich ruhig ergänzen.", + "complement": "Hilft beim Ergänzen, steht aber selten für sich allein.", + "topping": "Passt eher oben drauf oder als kleines Extra.", + "cooking": "Hilft beim Kochen oder Abschmecken, ist aber selten selbst die Mahlzeit.", + "snack": "Passt gut für kleine Zwischenmahlzeiten oder als ruhige Ergänzung.", + "solo": "Kann auch alleine als schnelle, einfache Mahlzeit funktionieren.", +} + +FOOD_ROLE_OPTIONS = [(key, label) for key, label in FOOD_ROLE_LABELS.items()] + +SUGGESTION_PRIORITY_OPTIONS = [ + ("prefer", "Gern vorschlagen"), + ("normal", "Normal vorschlagen"), + ("rare", "Eher selten automatisch vorschlagen"), + ("never", "Nie automatisch vorschlagen"), +] + +SUGGESTION_PRIORITY_LABELS = { + "prefer": "Gern vorschlagen", + "normal": "Normal vorschlagen", + "rare": "Eher selten automatisch vorschlagen", + "never": "Nie automatisch vorschlagen", +} + ENERGY_DENSITY_OPTIONS = [ ("low", "Eher leicht"), ("neutral", "Neutral"), @@ -74,12 +119,66 @@ SUGGESTION_STYLE_OPTIONS = [ ("balanced", "Eher ausgewogen"), ("fitness", "Fitness"), ("protein", "Proteinbetont"), + ("easy", "Leicht und einfach"), + ("snack", "Snackorientiert"), ] SUGGESTION_STYLE_LABELS = { "balanced": "Eher ausgewogen", "fitness": "Fitness", "protein": "Proteinbetont", + "easy": "Leicht und einfach", + "snack": "Snackorientiert", +} + +PROTEIN_PREFERENCE_OPTIONS = [ + ("mixed", "Offen gemischt"), + ("veg-friendly", "Überwiegend vegetarisch"), + ("rare-animal", "Fleisch und Fisch nur selten"), + ("plant-forward", "Möglichst pflanzlich"), +] + +PROTEIN_PREFERENCE_LABELS = { + "mixed": "Offen gemischt", + "veg-friendly": "Überwiegend vegetarisch", + "rare-animal": "Fleisch und Fisch nur selten", + "plant-forward": "Möglichst pflanzlich", +} + +MEAL_TYPE_OPTIONS = [ + ("breakfast", "Frühstück"), + ("lunch", "Mittagessen"), + ("dinner", "Abendessen"), + ("snack", "Snack"), +] + +MEAL_TYPE_LABELS = { + "breakfast": "Frühstück", + "lunch": "Mittagessen", + "dinner": "Abendessen", + "snack": "Snack", +} + +MEAL_STYLE_OPTIONS = [ + ("sweet", "Süß"), + ("savory", "Herzhaft"), + ("warm", "Warm"), + ("cold", "Kalt"), + ("quick", "Schnell"), + ("simple", "Ruhig und einfach"), + ("prep", "Gut vorbereitbar"), + ("portable", "Für unterwegs"), +] + +MEAL_STYLE_LABELS = { + "sweet": "Süß", + "savory": "Herzhaft", + "warm": "Warm", + "cold": "Kalt", + "quick": "Schnell", + "simple": "Ruhig und einfach", + "prep": "Gut vorbereitbar", + "portable": "Für unterwegs", } WEEKDAY_OPTIONS = [ diff --git a/nouri/db.py b/nouri/db.py index 7fc9563..56c3a50 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -8,10 +8,272 @@ from flask import Flask, current_app, g from flask.cli import with_appcontext from werkzeug.security import generate_password_hash -from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS +from .constants import ( + DAYPARTS, + DAYPART_SLUG_TO_MEAL_TYPE, + DEFAULT_CATEGORIES, + DEFAULT_CATEGORY_BUILDERS, +) CURRENT_SCHEMA_VERSION = "1.2.1" +ANIMAL_HINTS = ( + "huhn", + "hähn", + "rind", + "hack", + "schwein", + "speck", + "salami", + "wurst", + "thunfisch", + "lachs", + "fisch", + "garnelen", + "shrimp", + "sardinen", +) + + +def normalize_name_for_profile(name: str | None) -> str: + return (name or "").strip().lower() + + +def infer_food_profile(name: str | None, category: str | None, energy_density: str | None) -> dict[str, object]: + normalized_name = normalize_name_for_profile(name) + normalized_category = (category or "").strip().lower() + + base_type = "neutral" + if "eiweiß" in normalized_category or "protein" in normalized_category: + base_type = "protein" + elif "kohlenhyd" in normalized_category or "brot" in normalized_category or "getreide" in normalized_category: + base_type = "carb" + elif "milch" in normalized_category: + base_type = "dairy" + elif "obst" in normalized_category: + base_type = "fruit" + elif "gemüse" in normalized_category: + base_type = "veg" + elif "nüsse" in normalized_name or "nuss" in normalized_name: + base_type = "nuts" + elif "saat" in normalized_name or "leinsamen" in normalized_name or "chia" in normalized_name: + base_type = "seeds" + + suggestion_role = "base" + suggestion_priority = "normal" + can_be_meal_core = 0 + + if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")): + return { + "base_type": "protein", + "suggestion_role": "complement", + "suggestion_priority": "rare", + "can_be_meal_core": 0, + } + + if any(token in normalized_name for token in ("flohsamen", "flohsamenschalen", "leinsamen", "chia", "hanfsamen")): + return { + "base_type": "seeds", + "suggestion_role": "topping", + "suggestion_priority": "normal", + "can_be_meal_core": 0, + } + + if "tomatenmark" in normalized_name: + return { + "base_type": "neutral", + "suggestion_role": "cooking", + "suggestion_priority": "rare", + "can_be_meal_core": 0, + } + + if any(token in normalized_name for token in ("saure gurken", "essiggurken", "cornichons", "gurkenscheiben")): + return { + "base_type": "veg", + "suggestion_role": "complement", + "suggestion_priority": "rare", + "can_be_meal_core": 0, + } + + if any(token in normalized_name for token in ("tofu", "tempeh", "vegane schnitzel", "vegane nuggets", "veganes hack", "sojageschnetzeltes")): + return { + "base_type": "protein", + "suggestion_role": "main", + "suggestion_priority": "prefer", + "can_be_meal_core": 1, + } + + if any(token in normalized_name for token in ("thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "hack")): + return { + "base_type": "protein", + "suggestion_role": "main", + "suggestion_priority": "rare", + "can_be_meal_core": 1, + } + + if any(token in normalized_name for token in ("joghurt", "skyr", "quark", "hüttenkäse", "körniger frischkäse")): + return { + "base_type": "dairy", + "suggestion_role": "base", + "suggestion_priority": "prefer", + "can_be_meal_core": 1, + } + + if any(token in normalized_name for token in ("müsli", "hafer", "porridge", "cornflakes", "brot", "brötchen", "reis", "nudel", "kartoffel", "wrap")): + return { + "base_type": "carb", + "suggestion_role": "base", + "suggestion_priority": "normal", + "can_be_meal_core": 1, + } + + if any(token in normalized_name for token in ("beeren", "banane", "apfel", "obst", "birne", "trauben", "kiwi")): + return { + "base_type": "fruit", + "suggestion_role": "complement", + "suggestion_priority": "prefer", + "can_be_meal_core": 0, + } + + if any(token in normalized_name for token in ("gemüse", "brokkoli", "spinat", "erbsen", "paprika", "karotte", "zucchini", "salat", "tomate", "tk gemüse")): + return { + "base_type": "veg", + "suggestion_role": "complement", + "suggestion_priority": "prefer", + "can_be_meal_core": 0, + } + + if any(token in normalized_name for token in ("nussmus", "erdnuss", "mandeln", "walnüsse", "cashew")): + return { + "base_type": "nuts", + "suggestion_role": "topping", + "suggestion_priority": "normal", + "can_be_meal_core": 0, + } + + if any(token in normalized_name for token in ("terrine", "5-minuten", "instant", "cup noodles")): + return { + "base_type": "carb" if (energy_density or "neutral") != "high" else "neutral", + "suggestion_role": "solo", + "suggestion_priority": "rare", + "can_be_meal_core": 1, + } + + if base_type in {"protein", "carb", "dairy"}: + suggestion_role = "base" + can_be_meal_core = 1 + elif base_type in {"veg", "fruit"}: + suggestion_role = "complement" + elif base_type in {"nuts", "seeds"}: + suggestion_role = "topping" + + return { + "base_type": base_type, + "suggestion_role": suggestion_role, + "suggestion_priority": suggestion_priority, + "can_be_meal_core": can_be_meal_core, + } + + +def infer_meal_tags(name: str | None, legacy_category: str | None) -> str: + normalized_name = normalize_name_for_profile(name) + normalized_category = (legacy_category or "").strip().lower() + tags: list[str] = [] + + if normalized_category == "warmes": + tags.extend(["warm", "savory"]) + if normalized_category == "kleines essen": + tags.extend(["simple", "quick"]) + if normalized_category == "snack": + tags.append("simple") + if any(token in normalized_name for token in ("porridge", "müsli", "joghurt", "quark")): + tags.append("sweet") + if any(token in normalized_name for token in ("salat", "brot", "toast", "tofu", "reis", "nudel", "pfanne")): + tags.append("savory") + if any(token in normalized_name for token in ("to go", "unterwegs", "wrap")): + tags.append("portable") + if any(token in normalized_name for token in ("overnight", "vorbereitet", "meal prep")): + tags.append("prep") + if any(token in normalized_name for token in ("schnell", "5-minuten", "instant")): + tags.append("quick") + if any(token in normalized_name for token in ("einfach", "ruhig")): + tags.append("simple") + + unique_tags: list[str] = [] + for tag in tags: + if tag and tag not in unique_tags: + unique_tags.append(tag) + return ",".join(unique_tags) + + +def infer_meal_type_from_dayparts(database: sqlite3.Connection, item_id: int) -> str: + row = database.execute( + """ + SELECT dayparts.slug + FROM item_dayparts + JOIN dayparts ON dayparts.id = item_dayparts.daypart_id + WHERE item_dayparts.item_id = ? + ORDER BY dayparts.sort_order + LIMIT 1 + """, + (item_id,), + ).fetchone() + if row is None: + return "snack" + return DAYPART_SLUG_TO_MEAL_TYPE.get(row["slug"], "snack") + + +def migrate_item_profiles(database: sqlite3.Connection) -> None: + rows = database.execute( + """ + SELECT id, kind, name, category, energy_density + FROM items + ORDER BY id + """ + ).fetchall() + for row in rows: + item_id = int(row["id"]) + if row["kind"] == "food": + profile = infer_food_profile(row["name"], row["category"], row["energy_density"]) + database.execute( + """ + UPDATE items + SET base_type = ?, + suggestion_role = ?, + suggestion_priority = ?, + can_be_meal_core = ? + WHERE id = ? + """, + ( + profile["base_type"], + profile["suggestion_role"], + profile["suggestion_priority"], + profile["can_be_meal_core"], + item_id, + ), + ) + continue + + meal_type = infer_meal_type_from_dayparts(database, item_id) + meal_tags = infer_meal_tags(row["name"], row["category"]) + database.execute( + """ + UPDATE items + SET meal_type = COALESCE(NULLIF(meal_type, ''), ?), + meal_tags = CASE + WHEN meal_tags IS NULL OR meal_tags = '' THEN ? + ELSE meal_tags + END, + category = CASE + WHEN kind = 'meal' AND category IN ('Kohlenhydrate', 'Milchprodukt', 'Obst', 'Gemüse', 'Eiweißquelle', 'Snack', 'Warmes', 'Kleines Essen') + THEN NULL + ELSE category + END + WHERE id = ? + """, + (meal_type, meal_tags, item_id), + ) + def get_db() -> sqlite3.Connection: if "db" not in g: @@ -129,6 +391,12 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: add_column_if_missing(database, "items", "owner_user_id INTEGER") add_column_if_missing(database, "items", "target_user_id INTEGER") add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'") + add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'") + add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'") + add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0") + add_column_if_missing(database, "items", "meal_type TEXT") + add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''") add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'") if table_exists(database, "shopping_entries"): @@ -254,6 +522,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: if table_exists(database, "user_settings"): add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'") add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'") add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0") @@ -375,11 +644,18 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, table_name, "owner_user_id INTEGER") add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, "items", "target_user_id INTEGER") + add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'") + add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'") + add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0") + add_column_if_missing(database, "items", "meal_type TEXT") + add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''") add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'") add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'") add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'") add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0") @@ -422,6 +698,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''") sync_default_categories(database) + migrate_item_profiles(database) database.execute( """ INSERT OR IGNORE INTO user_settings (user_id) @@ -429,8 +706,14 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: """ ) database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''") + database.execute("UPDATE items SET base_type = 'neutral' WHERE base_type IS NULL OR base_type = ''") + database.execute("UPDATE items SET suggestion_role = 'base' WHERE suggestion_role IS NULL OR suggestion_role = ''") + database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''") + database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL") + database.execute("UPDATE items SET meal_tags = '' WHERE meal_tags IS NULL") database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''") database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''") + database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''") database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL") database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL") database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL") diff --git a/nouri/main.py b/nouri/main.py index c2ba3ac..7d54b87 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -26,17 +26,30 @@ from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_ba from .constants import ( AVAILABILITY_LABELS, BUILDER_LABELS, + BUILDER_OPTIONS, + DAYPART_SLUG_TO_MEAL_TYPE, DEFAULT_CATEGORY_BUILDERS, DAY_TEMPLATE_NAME_SUGGESTIONS, DEFAULT_CATEGORIES, ENERGY_DENSITY_LABELS, ENERGY_DENSITY_OPTIONS, + FOOD_ROLE_DESCRIPTIONS, + FOOD_ROLE_LABELS, + FOOD_ROLE_OPTIONS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, ITEM_SET_NAME_SUGGESTIONS, + MEAL_STYLE_LABELS, + MEAL_STYLE_OPTIONS, + MEAL_TYPE_LABELS, + MEAL_TYPE_OPTIONS, NOTIFICATION_CHANNEL_OPTIONS, + PROTEIN_PREFERENCE_LABELS, + PROTEIN_PREFERENCE_OPTIONS, SUGGESTION_STYLE_LABELS, SUGGESTION_STYLE_OPTIONS, + SUGGESTION_PRIORITY_LABELS, + SUGGESTION_PRIORITY_OPTIONS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, WEEKDAY_OPTIONS, @@ -196,6 +209,7 @@ def default_user_settings() -> dict: "notification_channel": "in_app", "suggestion_style": suggestion_style, "energy_preference": suggestion_style_energy_preference(suggestion_style), + "protein_preference": "mixed", "remind_before_shopping": True, "remind_on_shopping_day": True, "show_missing_for_upcoming_week": True, @@ -261,6 +275,7 @@ def get_user_settings() -> dict: settings["notification_channel"] = settings.get("notification_channel") or "in_app" settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced") settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"]) + settings["protein_preference"] = normalize_protein_preference(settings.get("protein_preference"), "mixed") return settings @@ -283,8 +298,6 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") -> def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str: allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS} - if raw == "easy" or raw == "snack": - return "balanced" return raw if raw in allowed else default @@ -293,9 +306,57 @@ def normalize_energy_density(raw: str | None, default: str = "neutral") -> str: return raw if raw in allowed else default +def normalize_base_type(raw: str | None, default: str = "neutral") -> str: + allowed = {value for value, _label in BUILDER_OPTIONS} + return raw if raw in allowed else default + + +def normalize_food_role(raw: str | None, default: str = "base") -> str: + allowed = {value for value, _label in FOOD_ROLE_OPTIONS} + return raw if raw in allowed else default + + +def normalize_suggestion_priority(raw: str | None, default: str = "normal") -> str: + allowed = {value for value, _label in SUGGESTION_PRIORITY_OPTIONS} + return raw if raw in allowed else default + + +def normalize_meal_type(raw: str | None, default: str = "snack") -> str: + allowed = {value for value, _label in MEAL_TYPE_OPTIONS} + return raw if raw in allowed else default + + +def normalize_meal_tags(values: list[str] | None) -> list[str]: + allowed = {value for value, _label in MEAL_STYLE_OPTIONS} + normalized: list[str] = [] + for value in values or []: + if value in allowed and value not in normalized: + normalized.append(value) + return normalized + + +def encode_tag_list(values: list[str] | None) -> str: + return ",".join(normalize_meal_tags(values)) + + +def decode_tag_list(raw: str | None) -> list[str]: + if not raw: + return [] + return normalize_meal_tags([part.strip() for part in str(raw).split(",") if part.strip()]) + + +def normalize_protein_preference(raw: str | None, default: str = "mixed") -> str: + allowed = {value for value, _label in PROTEIN_PREFERENCE_OPTIONS} + return raw if raw in allowed else default + + def suggestion_style_energy_preference(style: str) -> str: if style == "fitness": return "low" + if style == "easy": + return "low" + if style == "snack": + return "neutral" return "neutral" @@ -365,6 +426,23 @@ def describe_record(entry: dict) -> dict: entry["target_name"] = target_name entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral") entry["energy_density_label"] = ENERGY_DENSITY_LABELS.get(entry["energy_density"], ENERGY_DENSITY_LABELS["neutral"]) + entry["base_type"] = normalize_base_type(entry.get("base_type"), "neutral") + entry["base_type_label"] = BUILDER_LABELS.get(entry["base_type"], BUILDER_LABELS["neutral"]) + entry["suggestion_role"] = normalize_food_role(entry.get("suggestion_role"), "base") + entry["suggestion_role_label"] = FOOD_ROLE_LABELS.get(entry["suggestion_role"], FOOD_ROLE_LABELS["base"]) + entry["suggestion_priority"] = normalize_suggestion_priority(entry.get("suggestion_priority"), "normal") + entry["suggestion_priority_label"] = SUGGESTION_PRIORITY_LABELS.get( + entry["suggestion_priority"], + SUGGESTION_PRIORITY_LABELS["normal"], + ) + entry["can_be_meal_core"] = bool(entry.get("can_be_meal_core")) + entry["meal_type"] = normalize_meal_type( + entry.get("meal_type"), + DAYPART_SLUG_TO_MEAL_TYPE.get(entry.get("daypart_slug"), "snack"), + ) + entry["meal_type_label"] = MEAL_TYPE_LABELS.get(entry["meal_type"], MEAL_TYPE_LABELS["snack"]) + entry["meal_tags"] = decode_tag_list(entry.get("meal_tags")) + entry["meal_tag_labels"] = [MEAL_STYLE_LABELS.get(tag, tag) for tag in entry["meal_tags"]] entry["is_personal"] = entry.get("visibility") == "personal" entry["is_shared"] = entry.get("visibility") == "shared" entry["is_mine"] = entry.get("owner_user_id") == g.user["id"] @@ -516,7 +594,6 @@ def attach_builder_keys(items: list[dict]) -> list[dict]: if not items: return [] - category_builder_map = get_category_builder_map() meal_ids = [item["id"] for item in items if item["kind"] == "meal"] meal_builder_map: dict[int, set[str]] = defaultdict(set) @@ -525,7 +602,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]: rows = get_db().execute( f""" SELECT meal_components.meal_item_id, - component.category + component.base_type FROM meal_components JOIN items AS component ON component.id = meal_components.food_item_id WHERE meal_components.meal_item_id IN ({placeholders}) @@ -533,7 +610,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]: meal_ids, ).fetchall() for row in rows: - builder_key = category_builder_map.get(row["category"] or "", "neutral") + builder_key = normalize_base_type(row["base_type"], "neutral") meal_builder_map[int(row["meal_item_id"])].add(builder_key) for item in items: @@ -541,9 +618,9 @@ def attach_builder_keys(items: list[dict]) -> list[dict]: if item["kind"] == "meal": builder_keys = sorted(meal_builder_map.get(item["id"], set())) if not builder_keys: - builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")] + builder_keys = [normalize_base_type(item.get("base_type"), "neutral")] else: - builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")] + builder_keys = [normalize_base_type(item.get("base_type"), "neutral")] item["builder_keys"] = builder_keys item["builder_labels"] = [BUILDER_LABELS.get(key, BUILDER_LABELS["neutral"]) for key in builder_keys] item["primary_builder_key"] = builder_keys[0] if builder_keys else "neutral" @@ -557,23 +634,22 @@ def decorate_items(rows) -> list[dict]: def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]: if not item_ids: return {} - category_builder_map = get_category_builder_map() placeholders = ",".join("?" for _ in item_ids) rows = get_db().execute( f""" - SELECT id, kind, category + SELECT id, kind, base_type FROM items WHERE id IN ({placeholders}) """, item_ids, ).fetchall() - builder_map: dict[int, set[str]] = {int(row["id"]): {category_builder_map.get(row["category"] or "", "neutral")} for row in rows} + builder_map: dict[int, set[str]] = {int(row["id"]): {normalize_base_type(row["base_type"], "neutral")} for row in rows} meal_ids = [int(row["id"]) for row in rows if row["kind"] == "meal"] if meal_ids: meal_placeholders = ",".join("?" for _ in meal_ids) component_rows = get_db().execute( f""" - SELECT meal_components.meal_item_id, component.category + SELECT meal_components.meal_item_id, component.base_type FROM meal_components JOIN items AS component ON component.id = meal_components.food_item_id WHERE meal_components.meal_item_id IN ({meal_placeholders}) @@ -582,15 +658,74 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]: ).fetchall() for row in component_rows: builder_map.setdefault(int(row["meal_item_id"]), set()).add( - category_builder_map.get(row["category"] or "", "neutral") + normalize_base_type(row["base_type"], "neutral") ) return builder_map +def suggestion_priority_score(priority: str) -> int: + return { + "prefer": 8, + "normal": 3, + "rare": -6, + "never": -50, + }.get(priority, 0) + + +def is_animal_protein_item(item: dict) -> bool: + normalized = (item.get("name") or "").strip().lower() + return any( + token in normalized + for token in ("huhn", "hähn", "rind", "schwein", "speck", "salami", "wurst", "thunfisch", "lachs", "fisch", "garnelen", "shrimp", "sardinen") + ) + + +def protein_preference_score(item: dict, settings: dict) -> int: + preference = normalize_protein_preference(settings.get("protein_preference"), "mixed") + if not is_animal_protein_item(item): + return 2 if preference in {"veg-friendly", "rare-animal", "plant-forward"} else 0 + if preference == "mixed": + return 0 + if preference == "veg-friendly": + return -4 + if preference == "rare-animal": + return -8 + if preference == "plant-forward": + return -14 + return 0 + + +def meaningful_component(item: dict) -> bool: + role = normalize_food_role(item.get("suggestion_role"), "base") + if role in {"topping", "cooking"}: + return False + return bool(item.get("can_be_meal_core")) or role in {"base", "main", "solo", "snack", "complement"} + + +def food_supports_slot(food: dict, slot: dict) -> bool: + if normalize_suggestion_priority(food.get("suggestion_priority"), "normal") == "never": + return False + if slot.get("core_only") and not bool(food.get("can_be_meal_core")): + return False + + role = normalize_food_role(food.get("suggestion_role"), "base") + if slot.get("roles") and role not in slot["roles"]: + return False + + base_type = normalize_base_type(food.get("base_type"), "neutral") + accepted = set(slot.get("base_types", set())) + if "fiber" in accepted and base_type in {"veg", "fruit"}: + return True + return base_type in accepted + + def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int: - builder_keys = {key for item in component_items for key in item.get("builder_keys", ["neutral"])} + meaningful_items = [item for item in component_items if meaningful_component(item)] + builder_keys = {key for item in meaningful_items for key in item.get("builder_keys", ["neutral"])} energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items] - score = 0 + score = sum(suggestion_priority_score(normalize_suggestion_priority(item.get("suggestion_priority"), "normal")) for item in component_items) + score += sum(protein_preference_score(item, settings) for item in component_items) + score += sum(2 for item in component_items if bool(item.get("can_be_meal_core"))) style = settings.get("suggestion_style", "balanced") if style == "fitness": @@ -600,6 +735,12 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str, elif style == "protein": score += 8 if "protein" in builder_keys else 0 score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0 + elif style == "easy": + score += 5 if any(normalize_food_role(item.get("suggestion_role"), "base") in {"solo", "base"} for item in component_items) else 0 + score += 4 if len(component_items) <= 3 else -2 + elif style == "snack": + score += 5 if daypart_slug in {"morning-snack", "afternoon-snack", "late-snack"} else 0 + score += 3 if any(item.get("base_type") in {"fruit", "dairy", "nuts", "seeds"} for item in component_items) else 0 else: if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: score += 5 if "carb" in builder_keys else 0 @@ -607,7 +748,7 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str, else: score += 5 if "protein" in builder_keys else 0 score += 4 if "carb" in builder_keys else 0 - score += 4 if "veg" in builder_keys else 0 + score += 4 if builder_keys & {"veg", "fruit"} else 0 energy_preference = settings.get("energy_preference", "neutral") if style == "fitness": @@ -709,20 +850,44 @@ def group_items_by_availability(items: list[dict]) -> list[dict]: def extract_item_form_data(existing: dict | None = None) -> dict: form_data = existing or {} + daypart_ids = [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()] + meal_type_default = form_data.get("meal_type") or meal_type_for_daypart(daypart_ids[0] if daypart_ids else None) form_data.update( { "name": request.form.get("name", "").strip(), "category": request.form.get("category", "").strip(), + "base_type": normalize_base_type(request.form.get("base_type"), form_data.get("base_type", "neutral")), + "suggestion_role": normalize_food_role(request.form.get("suggestion_role"), form_data.get("suggestion_role", "base")), + "suggestion_priority": normalize_suggestion_priority( + request.form.get("suggestion_priority"), + form_data.get("suggestion_priority", "normal"), + ), + "can_be_meal_core": request.form.get("can_be_meal_core", "0") == "1", + "meal_type": normalize_meal_type(request.form.get("meal_type"), meal_type_default), + "meal_tags": normalize_meal_tags(request.form.getlist("meal_tags")), "energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")), "note": request.form.get("note", "").strip(), "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), "target_user_id": normalize_target_user_id(request.form.get("target_user_id")), "target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT), "food_search": request.form.get("food_search", "").strip(), - "daypart_ids": [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()], + "daypart_ids": daypart_ids, "component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()], "quick_food_name": request.form.get("quick_food_name", "").strip(), "quick_food_category": request.form.get("quick_food_category", "").strip(), + "quick_food_base_type": normalize_base_type( + request.form.get("quick_food_base_type"), + form_data.get("quick_food_base_type", "neutral"), + ), + "quick_food_role": normalize_food_role( + request.form.get("quick_food_role"), + form_data.get("quick_food_role", "base"), + ), + "quick_food_priority": normalize_suggestion_priority( + request.form.get("quick_food_priority"), + form_data.get("quick_food_priority", "normal"), + ), + "quick_food_can_be_meal_core": request.form.get("quick_food_can_be_meal_core", "0") == "1", "quick_food_energy_density": normalize_energy_density(request.form.get("quick_food_energy_density"), form_data.get("quick_food_energy_density", "neutral")), "quick_food_note": request.form.get("quick_food_note", "").strip(), } @@ -734,9 +899,9 @@ def create_quick_food_from_form(form_data: dict) -> int: cursor = get_db().execute( """ INSERT INTO items ( - household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, created_by, updated_by + household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, suggestion_role, suggestion_priority, can_be_meal_core, energy_density, note, created_by, updated_by ) - VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), @@ -745,6 +910,10 @@ def create_quick_food_from_form(form_data: dict) -> int: form_data["visibility"], form_data["quick_food_name"], form_data["quick_food_category"], + form_data["quick_food_base_type"], + form_data["quick_food_role"], + form_data["quick_food_priority"], + 1 if form_data["quick_food_can_be_meal_core"] else 0, form_data["quick_food_energy_density"], form_data["quick_food_note"], g.user["id"], @@ -1269,6 +1438,41 @@ def get_daypart_by_id(daypart_id: int): return None +def meal_type_for_daypart(daypart_id: int | None) -> str: + daypart = get_daypart_by_id(daypart_id) if daypart_id else None + if not daypart: + return "snack" + return DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack") + + +def meal_tags_for_generated_meal(daypart_id: int, foods: list[dict]) -> list[str]: + daypart = get_daypart_by_id(daypart_id) + slug = daypart["slug"] if daypart else "" + tags: list[str] = [] + + if slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: + if any(item.get("base_type") in {"fruit", "dairy"} for item in foods): + tags.append("sweet") + if any(item.get("base_type") == "carb" for item in foods): + tags.append("simple") + else: + if any(item.get("base_type") in {"protein", "veg"} for item in foods): + tags.append("savory") + if any(item.get("suggestion_role") == "solo" for item in foods): + tags.append("quick") + + if any(item.get("base_type") in {"dairy", "fruit"} for item in foods): + tags.append("cold") + if any(item.get("base_type") in {"protein", "veg"} for item in foods) and slug in {"lunch", "dinner"}: + tags.append("warm") + + normalized: list[str] = [] + for tag in tags: + if tag not in normalized: + normalized.append(tag) + return normalized + + def format_item_names(items: list[dict], limit: int = 3) -> str: return ", ".join(item["name"] for item in items[:limit]) @@ -1312,56 +1516,110 @@ def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str: return names[0] -def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: - if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: - target_patterns = [ +def meal_pattern_definitions(daypart_slug: str) -> list[dict]: + if daypart_slug == "breakfast": + return [ { - "slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}), - "reason": "Passt gut zu Frühstück oder Snack", + "reason": "Passt gut zu Frühstück", + "slots": [ + {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True}, + {"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False}, + {"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False}, + ], + }, + { + "reason": "Passt gut für Frühstück", + "slots": [ + {"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True}, + {"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False}, + {"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False}, + ], + }, + ] + + if daypart_slug in {"morning-snack", "afternoon-snack", "late-snack"}: + return [ + { + "reason": "Passt gut zu einem kleinen Snack", + "slots": [ + {"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True}, + {"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False}, + ], }, { - "slots": ({"carb"}, {"dairy", "protein"}), "reason": "Zuhause schnell kombinierbar", + "slots": [ + {"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True}, + {"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False}, + ], }, { - "slots": ({"dairy", "protein"}, {"fruit", "nuts", "seeds"}), - "reason": "Lässt sich gut als kleiner Snack vormerken", - }, - ] - else: - target_patterns = [ - { - "slots": ({"protein"}, {"carb"}, {"veg"}), - "reason": "Zuhause als vollständige Mahlzeit möglich", - }, - { - "slots": ({"protein"}, {"carb"}), - "reason": "Lässt sich leicht ergänzen", - }, - { - "slots": ({"protein"}, {"veg"}), - "reason": "Zuhause schon gut kombinierbar", - }, - { - "slots": ({"carb"}, {"veg"}), - "reason": "Daraus kann schnell etwas Einfaches werden", + "reason": "Passt gut zu einem kleinen Snack", + "slots": [ + {"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True}, + {"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False}, + ], }, ] + return [ + { + "reason": "Zuhause als vollständige Mahlzeit möglich", + "slots": [ + {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True}, + {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True}, + {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, + ], + }, + { + "reason": "Lässt sich gut ergänzen", + "slots": [ + {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True}, + {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, + {"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False}, + ], + }, + { + "reason": "Schnell und alltagstauglich", + "slots": [ + {"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True}, + {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, + ], + }, + ] + + +def score_food_for_pattern(food: dict, settings: dict) -> int: + score = suggestion_priority_score(normalize_suggestion_priority(food.get("suggestion_priority"), "normal")) + score += protein_preference_score(food, settings) + if bool(food.get("can_be_meal_core")): + score += 3 + role = normalize_food_role(food.get("suggestion_role"), "base") + if role == "main": + score += 3 + elif role == "base": + score += 2 + elif role == "solo": + score += 4 + return score + + +def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: + settings = get_user_settings() + target_patterns = meal_pattern_definitions(daypart_slug) + suggestions: list[dict] = [] seen_signatures: set[tuple[int, ...]] = set() - def slot_matches(food: dict, slot_keys: set[str]) -> bool: - return bool(slot_keys & set(food.get("builder_keys", ["neutral"]))) - for pattern in target_patterns: slot_candidates = [] - for slot_keys in pattern["slots"]: - matches = [food for food in home_foods if slot_matches(food, slot_keys)] + for slot in pattern["slots"]: + matches = [food for food in home_foods if food_supports_slot(food, slot)] + matches = sorted(matches, key=lambda food: (-score_food_for_pattern(food, settings), food["name"].lower())) if not matches: slot_candidates = [] break - slot_candidates.append(matches) + slot_candidates.append(matches[:6]) if not slot_candidates: continue @@ -1384,6 +1642,7 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li "needs_shopping": False, "is_generated": True, "suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]), + "score": score_suggestion_components(combo_items, daypart_slug=daypart_slug, settings=settings), } ) if len(suggestions) >= limit * 3: @@ -1511,14 +1770,25 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non present = set() for keys in builder_map.values(): present.update(keys) - target_order = ["protein", "carb", "veg"] - missing = [key for key in target_order if key not in present] + has_fiber = bool(present & {"veg", "fruit"}) + missing = [] + if "protein" not in present: + missing.append("protein") + if "carb" not in present: + missing.append("carb") + if not has_fiber: + missing.append("fiber") if not missing: return None first_missing = missing[0] home_matches = [ item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id) - if first_missing in item.get("builder_keys", []) + if ( + (first_missing == "fiber" and bool(set(item.get("builder_keys", [])) & {"veg", "fruit"})) + or first_missing in item.get("builder_keys", []) + ) + and meaningful_component(item) + and normalize_suggestion_priority(item.get("suggestion_priority"), "normal") != "never" ] home_matches = sorted( home_matches, @@ -1527,7 +1797,7 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non text_map = { "protein": "Dazu könnte noch eine Proteinquelle gut passen.", "carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.", - "veg": "Dazu könnte noch etwas Gemüse gut passen.", + "fiber": "Dazu könnte noch etwas Gemüse oder Obst gut passen.", } return { "text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."), @@ -1598,7 +1868,7 @@ def build_dashboard_hints(today: date) -> list[str]: visible_params(), ).fetchone() if int(dinner_home["count"]) > 0: - hints.append("Zuhause ist bereits etwas da, das gut zu Abendessen passt.") + hints.append("Für heute Abend ist zuhause schon etwas Passendes da.") if settings.get("remind_before_shopping") and (today + timedelta(days=1)).weekday() == household_settings["shopping_weekday"]: upcoming = fetch_upcoming_shopping_needs(limit=3) @@ -1608,7 +1878,7 @@ def build_dashboard_hints(today: date) -> list[str]: if settings.get("remind_nuts"): nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))] if nut_items: - hints.append("Heute schon an Nüsse gedacht?") + hints.append("Vielleicht passt heute noch etwas mit Nüssen oder Saaten dazu.") if settings.get("suggest_templates"): old_template = get_db().execute( @@ -2580,6 +2850,12 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict): form_data.get("category") or form_data.get("quick_food_category") ), form_data=form_data, + builder_options=[(key, label) for key, label in BUILDER_LABELS.items()], + food_role_options=FOOD_ROLE_OPTIONS, + food_role_descriptions=FOOD_ROLE_DESCRIPTIONS, + suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS, + meal_type_options=MEAL_TYPE_OPTIONS, + meal_style_options=MEAL_STYLE_OPTIONS, energy_density_options=ENERGY_DENSITY_OPTIONS, visibility_options=VISIBILITY_FORM_OPTIONS, target_user_options=get_target_user_options(), @@ -2634,27 +2910,52 @@ def create_or_get_generated_meal( get_db().execute( """ UPDATE items - SET updated_by = ?, updated_at = CURRENT_TIMESTAMP + SET meal_type = COALESCE(meal_type, ?), + meal_tags = CASE + WHEN COALESCE(meal_tags, '') = '' THEN ? + ELSE meal_tags + END, + updated_by = ?, + updated_at = CURRENT_TIMESTAMP WHERE id = ? """, - (g.user["id"], meal_id), + ( + meal_type_for_daypart(daypart_id), + encode_tag_list(meal_tags_for_generated_meal(daypart_id, fetch_items_by_ids(list(normalized_ids)))), + g.user["id"], + meal_id, + ), ) get_db().commit() return meal_id + component_foods = fetch_items_by_ids(list(normalized_ids)) cursor = get_db().execute( """ INSERT INTO items ( - household_id, owner_user_id, visibility, kind, name, category, created_by, updated_by + household_id, + owner_user_id, + visibility, + kind, + name, + category, + meal_type, + meal_tags, + energy_density, + created_by, + updated_by ) - VALUES (?, ?, ?, 'meal', ?, ?, ?, ?) + VALUES (?, ?, ?, 'meal', ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], visibility, name, - "Kleines Essen" if get_daypart_by_id(daypart_id)["slug"] in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} else "Warmes", + None, + meal_type_for_daypart(daypart_id), + encode_tag_list(meal_tags_for_generated_meal(daypart_id, component_foods)), + "neutral", g.user["id"], g.user["id"], ), @@ -3124,6 +3425,7 @@ def settings_view(): elif form_name == "reminders": ensure_user_settings_row() suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced") + protein_preference = normalize_protein_preference(request.form.get("protein_preference"), "mixed") get_db().execute( """ UPDATE user_settings @@ -3132,6 +3434,7 @@ def settings_view(): notification_channel = ?, suggestion_style = ?, energy_preference = ?, + protein_preference = ?, remind_before_shopping = ?, remind_on_shopping_day = ?, show_missing_for_upcoming_week = ?, @@ -3157,6 +3460,7 @@ def settings_view(): normalize_notification_channel(request.form.get("notification_channel"), "in_app"), suggestion_style, suggestion_style_energy_preference(suggestion_style), + protein_preference, parse_checkbox("remind_before_shopping", True), parse_checkbox("remind_on_shopping_day", True), parse_checkbox("show_missing_for_upcoming_week", True), @@ -3379,6 +3683,12 @@ def item_create(kind: str): form_data = { "name": request.args.get("name", "").strip(), "category": "", + "base_type": "neutral", + "suggestion_role": "base", + "suggestion_priority": "normal", + "can_be_meal_core": False, + "meal_type": normalize_meal_type(request.args.get("meal_type"), "snack"), + "meal_tags": [], "energy_density": "neutral", "note": "", "visibility": "shared", @@ -3389,6 +3699,10 @@ def item_create(kind: str): "component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()], "quick_food_name": "", "quick_food_category": "", + "quick_food_base_type": "neutral", + "quick_food_role": "base", + "quick_food_priority": "normal", + "quick_food_can_be_meal_core": False, "quick_food_energy_density": "neutral", "quick_food_note": "", } @@ -3428,9 +3742,9 @@ def item_create(kind: str): cursor = get_db().execute( """ INSERT INTO items ( - household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, photo_filename, created_by, updated_by + household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, suggestion_role, suggestion_priority, can_be_meal_core, meal_type, meal_tags, energy_density, note, photo_filename, created_by, updated_by ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), @@ -3439,7 +3753,13 @@ def item_create(kind: str): form_data["visibility"], kind, form_data["name"], - form_data["category"], + form_data["category"] if kind == "food" else None, + form_data["base_type"] if kind == "food" else "neutral", + form_data["suggestion_role"] if kind == "food" else "base", + form_data["suggestion_priority"] if kind == "food" else "normal", + 1 if (form_data["can_be_meal_core"] if kind == "food" else False) else 0, + form_data["meal_type"] if kind == "meal" else None, + encode_tag_list(form_data["meal_tags"]) if kind == "meal" else "", form_data["energy_density"], form_data["note"], photo_filename, @@ -3472,6 +3792,12 @@ def item_edit(item_id: int): form_data = { "name": item["name"], "category": item["category"] or "", + "base_type": item.get("base_type") or "neutral", + "suggestion_role": item.get("suggestion_role") or "base", + "suggestion_priority": item.get("suggestion_priority") or "normal", + "can_be_meal_core": bool(item.get("can_be_meal_core")), + "meal_type": item.get("meal_type") or meal_type_for_daypart(item.get("primary_daypart_id")), + "meal_tags": decode_tag_list(item.get("meal_tags")), "energy_density": item.get("energy_density") or "neutral", "note": item["note"] or "", "visibility": item["visibility"], @@ -3482,6 +3808,10 @@ def item_edit(item_id: int): "component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [], "quick_food_name": "", "quick_food_category": "", + "quick_food_base_type": "neutral", + "quick_food_role": "base", + "quick_food_priority": "normal", + "quick_food_can_be_meal_core": False, "quick_food_energy_density": "neutral", "quick_food_note": "", } @@ -3523,6 +3853,12 @@ def item_edit(item_id: int): UPDATE items SET name = ?, category = ?, + base_type = ?, + suggestion_role = ?, + suggestion_priority = ?, + can_be_meal_core = ?, + meal_type = ?, + meal_tags = ?, energy_density = ?, note = ?, visibility = ?, @@ -3534,7 +3870,13 @@ def item_edit(item_id: int): """, ( form_data["name"], - form_data["category"], + form_data["category"] if item["kind"] == "food" else None, + form_data["base_type"] if item["kind"] == "food" else "neutral", + form_data["suggestion_role"] if item["kind"] == "food" else "base", + form_data["suggestion_priority"] if item["kind"] == "food" else "normal", + 1 if (form_data["can_be_meal_core"] if item["kind"] == "food" else False) else 0, + form_data["meal_type"] if item["kind"] == "meal" else None, + encode_tag_list(form_data["meal_tags"]) if item["kind"] == "meal" else "", form_data["energy_density"], form_data["note"], form_data["visibility"], diff --git a/nouri/schema.sql b/nouri/schema.sql index d904aac..44d9567 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS user_settings ( notification_channel TEXT NOT NULL DEFAULT 'in_app', suggestion_style TEXT NOT NULL DEFAULT 'balanced', energy_preference TEXT NOT NULL DEFAULT 'neutral', + protein_preference TEXT NOT NULL DEFAULT 'mixed', remind_before_shopping INTEGER NOT NULL DEFAULT 1, remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1, @@ -120,6 +121,12 @@ CREATE TABLE IF NOT EXISTS items ( kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')), name TEXT NOT NULL, category TEXT, + base_type TEXT NOT NULL DEFAULT 'neutral', + suggestion_role TEXT NOT NULL DEFAULT 'base', + suggestion_priority TEXT NOT NULL DEFAULT 'normal', + can_be_meal_core INTEGER NOT NULL DEFAULT 0, + meal_type TEXT, + meal_tags TEXT NOT NULL DEFAULT '', energy_density TEXT NOT NULL DEFAULT 'neutral', note TEXT, photo_filename TEXT,