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, DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS, ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS, ENERGY_DENSITY_OPTIONS,
FOOD_ROLE_DESCRIPTIONS,
FOOD_ROLE_LABELS,
FOOD_ROLE_OPTIONS,
ITEM_KIND_LABELS, ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS, ITEM_KIND_SINGULAR_LABELS,
MEAL_STYLE_LABELS,
MEAL_STYLE_OPTIONS,
MEAL_TYPE_LABELS,
MEAL_TYPE_OPTIONS,
NOTIFICATION_CHANNEL_OPTIONS, NOTIFICATION_CHANNEL_OPTIONS,
PROTEIN_PREFERENCE_LABELS,
PROTEIN_PREFERENCE_OPTIONS,
ROLE_LABELS, ROLE_LABELS,
SUGGESTION_PRIORITY_LABELS,
SUGGESTION_PRIORITY_OPTIONS,
SUGGESTION_STYLE_LABELS, SUGGESTION_STYLE_LABELS,
SUGGESTION_STYLE_OPTIONS, SUGGESTION_STYLE_OPTIONS,
VISIBILITY_DESCRIPTIONS, VISIBILITY_DESCRIPTIONS,
@@ -140,11 +151,22 @@ def create_app() -> Flask:
"builder_labels": BUILDER_LABELS, "builder_labels": BUILDER_LABELS,
"builder_descriptions": BUILDER_DESCRIPTIONS, "builder_descriptions": BUILDER_DESCRIPTIONS,
"builder_options": BUILDER_OPTIONS, "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, "daypart_suggestions": DAYPARTS,
"energy_density_options": ENERGY_DENSITY_OPTIONS, "energy_density_options": ENERGY_DENSITY_OPTIONS,
"energy_density_labels": ENERGY_DENSITY_LABELS, "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_options": SUGGESTION_STYLE_OPTIONS,
"suggestion_style_labels": SUGGESTION_STYLE_LABELS, "suggestion_style_labels": SUGGESTION_STYLE_LABELS,
"protein_preference_options": PROTEIN_PREFERENCE_OPTIONS,
"protein_preference_labels": PROTEIN_PREFERENCE_LABELS,
"visibility_labels": VISIBILITY_LABELS, "visibility_labels": VISIBILITY_LABELS,
"visibility_descriptions": VISIBILITY_DESCRIPTIONS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS,
"role_labels": ROLE_LABELS, "role_labels": ROLE_LABELS,
+99
View File
@@ -7,6 +7,15 @@ DAYPARTS = [
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60}, {"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 = [ DEFAULT_CATEGORIES = [
"Kohlenhydrate", "Kohlenhydrate",
"Milchprodukt", "Milchprodukt",
@@ -58,6 +67,42 @@ BUILDER_DESCRIPTIONS = {
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()] 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 = [ ENERGY_DENSITY_OPTIONS = [
("low", "Eher leicht"), ("low", "Eher leicht"),
("neutral", "Neutral"), ("neutral", "Neutral"),
@@ -74,12 +119,66 @@ SUGGESTION_STYLE_OPTIONS = [
("balanced", "Eher ausgewogen"), ("balanced", "Eher ausgewogen"),
("fitness", "Fitness"), ("fitness", "Fitness"),
("protein", "Proteinbetont"), ("protein", "Proteinbetont"),
("easy", "Leicht und einfach"),
("snack", "Snackorientiert"),
] ]
SUGGESTION_STYLE_LABELS = { SUGGESTION_STYLE_LABELS = {
"balanced": "Eher ausgewogen", "balanced": "Eher ausgewogen",
"fitness": "Fitness", "fitness": "Fitness",
"protein": "Proteinbetont", "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 = [ WEEKDAY_OPTIONS = [
+284 -1
View File
@@ -8,10 +8,272 @@ from flask import Flask, current_app, g
from flask.cli import with_appcontext from flask.cli import with_appcontext
from werkzeug.security import generate_password_hash 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" 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: def get_db() -> sqlite3.Connection:
if "db" not in g: 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", "owner_user_id INTEGER")
add_column_if_missing(database, "items", "target_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", "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'") add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
if table_exists(database, "shopping_entries"): if table_exists(database, "shopping_entries"):
@@ -254,6 +522,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
if table_exists(database, "user_settings"): 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", "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", "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_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_lunch INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_dinner 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, "owner_user_id INTEGER")
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'") 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", "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, "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_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") 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", "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", "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_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_lunch INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_dinner 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 = ''") database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
sync_default_categories(database) sync_default_categories(database)
migrate_item_profiles(database)
database.execute( database.execute(
""" """
INSERT OR IGNORE INTO user_settings (user_id) 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 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 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 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_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_lunch = 0 WHERE push_missing_lunch IS NULL")
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner 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 ( from .constants import (
AVAILABILITY_LABELS, AVAILABILITY_LABELS,
BUILDER_LABELS, BUILDER_LABELS,
BUILDER_OPTIONS,
DAYPART_SLUG_TO_MEAL_TYPE,
DEFAULT_CATEGORY_BUILDERS, DEFAULT_CATEGORY_BUILDERS,
DAY_TEMPLATE_NAME_SUGGESTIONS, DAY_TEMPLATE_NAME_SUGGESTIONS,
DEFAULT_CATEGORIES, DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS, ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS, ENERGY_DENSITY_OPTIONS,
FOOD_ROLE_DESCRIPTIONS,
FOOD_ROLE_LABELS,
FOOD_ROLE_OPTIONS,
ITEM_KIND_LABELS, ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS, ITEM_KIND_SINGULAR_LABELS,
ITEM_SET_NAME_SUGGESTIONS, ITEM_SET_NAME_SUGGESTIONS,
MEAL_STYLE_LABELS,
MEAL_STYLE_OPTIONS,
MEAL_TYPE_LABELS,
MEAL_TYPE_OPTIONS,
NOTIFICATION_CHANNEL_OPTIONS, NOTIFICATION_CHANNEL_OPTIONS,
PROTEIN_PREFERENCE_LABELS,
PROTEIN_PREFERENCE_OPTIONS,
SUGGESTION_STYLE_LABELS, SUGGESTION_STYLE_LABELS,
SUGGESTION_STYLE_OPTIONS, SUGGESTION_STYLE_OPTIONS,
SUGGESTION_PRIORITY_LABELS,
SUGGESTION_PRIORITY_OPTIONS,
VISIBILITY_DESCRIPTIONS, VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS, VISIBILITY_LABELS,
WEEKDAY_OPTIONS, WEEKDAY_OPTIONS,
@@ -196,6 +209,7 @@ def default_user_settings() -> dict:
"notification_channel": "in_app", "notification_channel": "in_app",
"suggestion_style": suggestion_style, "suggestion_style": suggestion_style,
"energy_preference": suggestion_style_energy_preference(suggestion_style), "energy_preference": suggestion_style_energy_preference(suggestion_style),
"protein_preference": "mixed",
"remind_before_shopping": True, "remind_before_shopping": True,
"remind_on_shopping_day": True, "remind_on_shopping_day": True,
"show_missing_for_upcoming_week": 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["notification_channel"] = settings.get("notification_channel") or "in_app"
settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced") settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced")
settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"]) settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"])
settings["protein_preference"] = normalize_protein_preference(settings.get("protein_preference"), "mixed")
return settings 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: def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str:
allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS} 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 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 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: def suggestion_style_energy_preference(style: str) -> str:
if style == "fitness": if style == "fitness":
return "low" return "low"
if style == "easy":
return "low"
if style == "snack":
return "neutral"
return "neutral" return "neutral"
@@ -365,6 +426,23 @@ def describe_record(entry: dict) -> dict:
entry["target_name"] = target_name entry["target_name"] = target_name
entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral") 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["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_personal"] = entry.get("visibility") == "personal"
entry["is_shared"] = entry.get("visibility") == "shared" entry["is_shared"] = entry.get("visibility") == "shared"
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"] 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: if not items:
return [] return []
category_builder_map = get_category_builder_map()
meal_ids = [item["id"] for item in items if item["kind"] == "meal"] meal_ids = [item["id"] for item in items if item["kind"] == "meal"]
meal_builder_map: dict[int, set[str]] = defaultdict(set) 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( rows = get_db().execute(
f""" f"""
SELECT meal_components.meal_item_id, SELECT meal_components.meal_item_id,
component.category component.base_type
FROM meal_components FROM meal_components
JOIN items AS component ON component.id = meal_components.food_item_id JOIN items AS component ON component.id = meal_components.food_item_id
WHERE meal_components.meal_item_id IN ({placeholders}) WHERE meal_components.meal_item_id IN ({placeholders})
@@ -533,7 +610,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
meal_ids, meal_ids,
).fetchall() ).fetchall()
for row in rows: 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) meal_builder_map[int(row["meal_item_id"])].add(builder_key)
for item in items: for item in items:
@@ -541,9 +618,9 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
if item["kind"] == "meal": if item["kind"] == "meal":
builder_keys = sorted(meal_builder_map.get(item["id"], set())) builder_keys = sorted(meal_builder_map.get(item["id"], set()))
if not builder_keys: 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: 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_keys"] = builder_keys
item["builder_labels"] = [BUILDER_LABELS.get(key, BUILDER_LABELS["neutral"]) for key in 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" 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]]: def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
if not item_ids: if not item_ids:
return {} return {}
category_builder_map = get_category_builder_map()
placeholders = ",".join("?" for _ in item_ids) placeholders = ",".join("?" for _ in item_ids)
rows = get_db().execute( rows = get_db().execute(
f""" f"""
SELECT id, kind, category SELECT id, kind, base_type
FROM items FROM items
WHERE id IN ({placeholders}) WHERE id IN ({placeholders})
""", """,
item_ids, item_ids,
).fetchall() ).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"] meal_ids = [int(row["id"]) for row in rows if row["kind"] == "meal"]
if meal_ids: if meal_ids:
meal_placeholders = ",".join("?" for _ in meal_ids) meal_placeholders = ",".join("?" for _ in meal_ids)
component_rows = get_db().execute( component_rows = get_db().execute(
f""" f"""
SELECT meal_components.meal_item_id, component.category SELECT meal_components.meal_item_id, component.base_type
FROM meal_components FROM meal_components
JOIN items AS component ON component.id = meal_components.food_item_id JOIN items AS component ON component.id = meal_components.food_item_id
WHERE meal_components.meal_item_id IN ({meal_placeholders}) 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() ).fetchall()
for row in component_rows: for row in component_rows:
builder_map.setdefault(int(row["meal_item_id"]), set()).add( 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 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: 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] 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") style = settings.get("suggestion_style", "balanced")
if style == "fitness": if style == "fitness":
@@ -600,6 +735,12 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str,
elif style == "protein": elif style == "protein":
score += 8 if "protein" in builder_keys else 0 score += 8 if "protein" in builder_keys else 0
score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" 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: else:
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
score += 5 if "carb" in builder_keys else 0 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: else:
score += 5 if "protein" in builder_keys else 0 score += 5 if "protein" in builder_keys else 0
score += 4 if "carb" 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") energy_preference = settings.get("energy_preference", "neutral")
if style == "fitness": 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: def extract_item_form_data(existing: dict | None = None) -> dict:
form_data = existing or {} 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( form_data.update(
{ {
"name": request.form.get("name", "").strip(), "name": request.form.get("name", "").strip(),
"category": request.form.get("category", "").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")), "energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")),
"note": request.form.get("note", "").strip(), "note": request.form.get("note", "").strip(),
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), "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_id": normalize_target_user_id(request.form.get("target_user_id")),
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT), "target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
"food_search": request.form.get("food_search", "").strip(), "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()], "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_name": request.form.get("quick_food_name", "").strip(),
"quick_food_category": request.form.get("quick_food_category", "").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_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(), "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( cursor = get_db().execute(
""" """
INSERT INTO items ( 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(), current_household_id(),
@@ -745,6 +910,10 @@ def create_quick_food_from_form(form_data: dict) -> int:
form_data["visibility"], form_data["visibility"],
form_data["quick_food_name"], form_data["quick_food_name"],
form_data["quick_food_category"], 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_energy_density"],
form_data["quick_food_note"], form_data["quick_food_note"],
g.user["id"], g.user["id"],
@@ -1269,6 +1438,41 @@ def get_daypart_by_id(daypart_id: int):
return None 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: def format_item_names(items: list[dict], limit: int = 3) -> str:
return ", ".join(item["name"] for item in items[:limit]) 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] return names[0]
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: if daypart_slug == "breakfast":
target_patterns = [ return [
{ {
"slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}), "reason": "Passt gut zu Frühstück",
"reason": "Passt gut zu Frühstück oder Snack", "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", "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": "Passt gut zu einem kleinen Snack",
"reason": "Lässt sich gut als kleiner Snack vormerken", "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", "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 gut ergänzen",
"reason": "Lässt sich leicht 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": "Schnell und alltagstauglich",
"reason": "Zuhause schon gut kombinierbar", "slots": [
}, {"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True},
{ {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
"slots": ({"carb"}, {"veg"}), ],
"reason": "Daraus kann schnell etwas Einfaches werden",
}, },
] ]
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] = [] suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set() 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: for pattern in target_patterns:
slot_candidates = [] slot_candidates = []
for slot_keys in pattern["slots"]: for slot in pattern["slots"]:
matches = [food for food in home_foods if slot_matches(food, slot_keys)] 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: if not matches:
slot_candidates = [] slot_candidates = []
break break
slot_candidates.append(matches) slot_candidates.append(matches[:6])
if not slot_candidates: if not slot_candidates:
continue continue
@@ -1384,6 +1642,7 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
"needs_shopping": False, "needs_shopping": False,
"is_generated": True, "is_generated": True,
"suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]), "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: if len(suggestions) >= limit * 3:
@@ -1511,14 +1770,25 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
present = set() present = set()
for keys in builder_map.values(): for keys in builder_map.values():
present.update(keys) present.update(keys)
target_order = ["protein", "carb", "veg"] has_fiber = bool(present & {"veg", "fruit"})
missing = [key for key in target_order if key not in present] 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: if not missing:
return None return None
first_missing = missing[0] first_missing = missing[0]
home_matches = [ home_matches = [
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id) 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 = sorted(
home_matches, home_matches,
@@ -1527,7 +1797,7 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
text_map = { text_map = {
"protein": "Dazu könnte noch eine Proteinquelle gut passen.", "protein": "Dazu könnte noch eine Proteinquelle gut passen.",
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.", "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 { return {
"text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."), "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(), visible_params(),
).fetchone() ).fetchone()
if int(dinner_home["count"]) > 0: 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"]: if settings.get("remind_before_shopping") and (today + timedelta(days=1)).weekday() == household_settings["shopping_weekday"]:
upcoming = fetch_upcoming_shopping_needs(limit=3) upcoming = fetch_upcoming_shopping_needs(limit=3)
@@ -1608,7 +1878,7 @@ def build_dashboard_hints(today: date) -> list[str]:
if settings.get("remind_nuts"): 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", []))] nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))]
if nut_items: 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"): if settings.get("suggest_templates"):
old_template = get_db().execute( 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.get("category") or form_data.get("quick_food_category")
), ),
form_data=form_data, 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, energy_density_options=ENERGY_DENSITY_OPTIONS,
visibility_options=VISIBILITY_FORM_OPTIONS, visibility_options=VISIBILITY_FORM_OPTIONS,
target_user_options=get_target_user_options(), target_user_options=get_target_user_options(),
@@ -2634,27 +2910,52 @@ def create_or_get_generated_meal(
get_db().execute( get_db().execute(
""" """
UPDATE items 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 = ? 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() get_db().commit()
return meal_id return meal_id
component_foods = fetch_items_by_ids(list(normalized_ids))
cursor = get_db().execute( cursor = get_db().execute(
""" """
INSERT INTO items ( 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(), current_household_id(),
g.user["id"], g.user["id"],
visibility, visibility,
name, 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"],
g.user["id"], g.user["id"],
), ),
@@ -3124,6 +3425,7 @@ def settings_view():
elif form_name == "reminders": elif form_name == "reminders":
ensure_user_settings_row() ensure_user_settings_row()
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced") 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( get_db().execute(
""" """
UPDATE user_settings UPDATE user_settings
@@ -3132,6 +3434,7 @@ def settings_view():
notification_channel = ?, notification_channel = ?,
suggestion_style = ?, suggestion_style = ?,
energy_preference = ?, energy_preference = ?,
protein_preference = ?,
remind_before_shopping = ?, remind_before_shopping = ?,
remind_on_shopping_day = ?, remind_on_shopping_day = ?,
show_missing_for_upcoming_week = ?, show_missing_for_upcoming_week = ?,
@@ -3157,6 +3460,7 @@ def settings_view():
normalize_notification_channel(request.form.get("notification_channel"), "in_app"), normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
suggestion_style, suggestion_style,
suggestion_style_energy_preference(suggestion_style), suggestion_style_energy_preference(suggestion_style),
protein_preference,
parse_checkbox("remind_before_shopping", True), parse_checkbox("remind_before_shopping", True),
parse_checkbox("remind_on_shopping_day", True), parse_checkbox("remind_on_shopping_day", True),
parse_checkbox("show_missing_for_upcoming_week", True), parse_checkbox("show_missing_for_upcoming_week", True),
@@ -3379,6 +3683,12 @@ def item_create(kind: str):
form_data = { form_data = {
"name": request.args.get("name", "").strip(), "name": request.args.get("name", "").strip(),
"category": "", "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", "energy_density": "neutral",
"note": "", "note": "",
"visibility": "shared", "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()], "component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
"quick_food_name": "", "quick_food_name": "",
"quick_food_category": "", "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_energy_density": "neutral",
"quick_food_note": "", "quick_food_note": "",
} }
@@ -3428,9 +3742,9 @@ def item_create(kind: str):
cursor = get_db().execute( cursor = get_db().execute(
""" """
INSERT INTO items ( 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(), current_household_id(),
@@ -3439,7 +3753,13 @@ def item_create(kind: str):
form_data["visibility"], form_data["visibility"],
kind, kind,
form_data["name"], 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["energy_density"],
form_data["note"], form_data["note"],
photo_filename, photo_filename,
@@ -3472,6 +3792,12 @@ def item_edit(item_id: int):
form_data = { form_data = {
"name": item["name"], "name": item["name"],
"category": item["category"] or "", "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", "energy_density": item.get("energy_density") or "neutral",
"note": item["note"] or "", "note": item["note"] or "",
"visibility": item["visibility"], "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 [], "component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
"quick_food_name": "", "quick_food_name": "",
"quick_food_category": "", "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_energy_density": "neutral",
"quick_food_note": "", "quick_food_note": "",
} }
@@ -3523,6 +3853,12 @@ def item_edit(item_id: int):
UPDATE items UPDATE items
SET name = ?, SET name = ?,
category = ?, category = ?,
base_type = ?,
suggestion_role = ?,
suggestion_priority = ?,
can_be_meal_core = ?,
meal_type = ?,
meal_tags = ?,
energy_density = ?, energy_density = ?,
note = ?, note = ?,
visibility = ?, visibility = ?,
@@ -3534,7 +3870,13 @@ def item_edit(item_id: int):
""", """,
( (
form_data["name"], 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["energy_density"],
form_data["note"], form_data["note"],
form_data["visibility"], 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', notification_channel TEXT NOT NULL DEFAULT 'in_app',
suggestion_style TEXT NOT NULL DEFAULT 'balanced', suggestion_style TEXT NOT NULL DEFAULT 'balanced',
energy_preference TEXT NOT NULL DEFAULT 'neutral', energy_preference TEXT NOT NULL DEFAULT 'neutral',
protein_preference TEXT NOT NULL DEFAULT 'mixed',
remind_before_shopping INTEGER NOT NULL DEFAULT 1, remind_before_shopping INTEGER NOT NULL DEFAULT 1,
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week 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')), kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
name TEXT NOT NULL, name TEXT NOT NULL,
category TEXT, 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', energy_density TEXT NOT NULL DEFAULT 'neutral',
note TEXT, note TEXT,
photo_filename TEXT, photo_filename TEXT,