Restructure food suggestion data and builder logic

This commit is contained in:
2026-04-13 17:55:11 +02:00
parent 6c7c1f01c9
commit 305440a6b2
5 changed files with 820 additions and 67 deletions
+22
View File
@@ -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,
+99
View File
@@ -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 = [
+284 -1
View File
@@ -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")
+400 -58
View File
@@ -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",
"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},
],
},
]
else:
target_patterns = [
return [
{
"slots": ({"protein"}, {"carb"}, {"veg"}),
"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},
],
},
{
"slots": ({"protein"}, {"carb"}),
"reason": "Lässt sich leicht ergänzen",
"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},
],
},
{
"slots": ({"protein"}, {"veg"}),
"reason": "Zuhause schon gut kombinierbar",
},
{
"slots": ({"carb"}, {"veg"}),
"reason": "Daraus kann schnell etwas Einfaches werden",
"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"],
+7
View File
@@ -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,