Compare commits
5 Commits
V1.2.2
...
216dde1414
| Author | SHA1 | Date | |
|---|---|---|---|
| 216dde1414 | |||
| 6f6269c66d | |||
| c5dea16c53 | |||
| e057cf0382 | |||
| 5a1c1d5c41 |
@@ -67,6 +67,24 @@ BUILDER_DESCRIPTIONS = {
|
||||
|
||||
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
||||
|
||||
FOOD_FLAVOR_OPTIONS = [
|
||||
("neutral", "Neutral"),
|
||||
("sweet", "Süß"),
|
||||
("savory", "Herzhaft"),
|
||||
]
|
||||
|
||||
FOOD_FLAVOR_LABELS = {
|
||||
"neutral": "Neutral",
|
||||
"sweet": "Süß",
|
||||
"savory": "Herzhaft",
|
||||
}
|
||||
|
||||
FOOD_FLAVOR_DESCRIPTIONS = {
|
||||
"neutral": "Passt ruhig in beide Richtungen und bleibt flexibel.",
|
||||
"sweet": "Passt eher zu süßen Kombinationen, Frühstücksideen oder kleinen Snacks.",
|
||||
"savory": "Passt eher zu herzhaften Kombinationen und ruhigeren Hauptmahlzeiten.",
|
||||
}
|
||||
|
||||
FOOD_ROLE_LABELS = {
|
||||
"main": "Hauptbaustein",
|
||||
"base": "Basis",
|
||||
|
||||
@@ -39,6 +39,39 @@ def normalize_name_for_profile(name: str | None) -> str:
|
||||
return (name or "").strip().lower()
|
||||
|
||||
|
||||
def infer_food_flavor_profile(
|
||||
name: str | None,
|
||||
category: str | None,
|
||||
base_type: str | None = None,
|
||||
suggestion_role: str | None = None,
|
||||
) -> str:
|
||||
normalized_name = normalize_name_for_profile(name)
|
||||
normalized_category = (category or "").strip().lower()
|
||||
normalized_base_type = (base_type or "").strip().lower()
|
||||
normalized_role = (suggestion_role or "").strip().lower()
|
||||
|
||||
if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")):
|
||||
return "neutral"
|
||||
if any(token in normalized_name for token in ("schoko", "choco", "müsli", "granola", "cornflakes", "fruchtjoghurt", "vanillejoghurt", "pudding")):
|
||||
return "sweet"
|
||||
if any(token in normalized_name for token in ("banane", "apfel", "birne", "beeren", "himbeer", "erdbeer", "heidelbeer", "mango", "kiwi", "trauben")):
|
||||
return "sweet"
|
||||
if any(token in normalized_name for token in ("räucher", "tofu", "tempeh", "hack", "salami", "wurst", "thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "aufstrich", "pesto", "humus", "hummus", "reisgericht", "chili", "curry")):
|
||||
return "savory"
|
||||
if any(token in normalized_name for token in ("naturjoghurt", "joghurt natur", "quark", "skyr", "haferflocken", "gurke", "karotte", "karotten", "kartoffel", "kartoffeln", "reis", "nudeln", "brot", "brötchen")):
|
||||
return "neutral"
|
||||
|
||||
if "obst" in normalized_category or normalized_base_type == "fruit":
|
||||
return "sweet"
|
||||
if any(token in normalized_category for token in ("eiweiß", "protein")) or normalized_base_type == "protein":
|
||||
return "savory"
|
||||
if any(token in normalized_category for token in ("gemüse",)) or normalized_base_type in {"veg", "carb", "dairy", "nuts", "seeds"}:
|
||||
return "neutral"
|
||||
if normalized_role in {"topping", "cooking"}:
|
||||
return "neutral"
|
||||
return "neutral"
|
||||
|
||||
|
||||
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()
|
||||
@@ -275,6 +308,42 @@ def migrate_item_profiles(database: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None:
|
||||
if get_meta(database, "food_flavor_profiles_migrated") == "1":
|
||||
return
|
||||
|
||||
rows = database.execute(
|
||||
"""
|
||||
SELECT id, name, category, base_type, suggestion_role, flavor_profile
|
||||
FROM items
|
||||
WHERE kind = 'food'
|
||||
ORDER BY id
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
current_flavor = (row["flavor_profile"] or "").strip().lower()
|
||||
if current_flavor in {"sweet", "savory"}:
|
||||
continue
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET flavor_profile = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
infer_food_flavor_profile(
|
||||
row["name"],
|
||||
row["category"],
|
||||
row["base_type"],
|
||||
row["suggestion_role"],
|
||||
),
|
||||
int(row["id"]),
|
||||
),
|
||||
)
|
||||
|
||||
set_meta(database, "food_flavor_profiles_migrated", "1")
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(
|
||||
@@ -392,6 +461,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||
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", "flavor_profile 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")
|
||||
@@ -645,6 +715,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
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", "flavor_profile 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")
|
||||
@@ -699,6 +770,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
|
||||
sync_default_categories(database)
|
||||
migrate_item_profiles(database)
|
||||
migrate_food_flavor_profiles(database)
|
||||
database.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_settings (user_id)
|
||||
@@ -707,6 +779,7 @@ 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 flavor_profile = 'neutral' WHERE flavor_profile IS NULL OR flavor_profile = ''")
|
||||
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")
|
||||
|
||||
@@ -33,6 +33,9 @@ from .constants import (
|
||||
DEFAULT_CATEGORIES,
|
||||
ENERGY_DENSITY_LABELS,
|
||||
ENERGY_DENSITY_OPTIONS,
|
||||
FOOD_FLAVOR_DESCRIPTIONS,
|
||||
FOOD_FLAVOR_LABELS,
|
||||
FOOD_FLAVOR_OPTIONS,
|
||||
FOOD_ROLE_DESCRIPTIONS,
|
||||
FOOD_ROLE_LABELS,
|
||||
FOOD_ROLE_OPTIONS,
|
||||
@@ -311,6 +314,11 @@ def normalize_base_type(raw: str | None, default: str = "neutral") -> str:
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
def normalize_food_flavor(raw: str | None, default: str = "neutral") -> str:
|
||||
allowed = {value for value, _label in FOOD_FLAVOR_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
|
||||
@@ -428,6 +436,8 @@ def describe_record(entry: dict) -> dict:
|
||||
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["flavor_profile"] = normalize_food_flavor(entry.get("flavor_profile"), "neutral")
|
||||
entry["flavor_profile_label"] = FOOD_FLAVOR_LABELS.get(entry["flavor_profile"], FOOD_FLAVOR_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")
|
||||
@@ -714,12 +724,29 @@ def food_supports_slot(food: dict, slot: dict) -> bool:
|
||||
|
||||
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"}:
|
||||
matches_base_type = ("fiber" in accepted and base_type in {"veg", "fruit"}) or base_type in accepted
|
||||
if not matches_base_type:
|
||||
return False
|
||||
|
||||
accepted_flavors = set(slot.get("flavors", set()))
|
||||
if not accepted_flavors:
|
||||
return True
|
||||
return base_type in accepted
|
||||
return normalize_food_flavor(food.get("flavor_profile"), "neutral") in accepted_flavors
|
||||
|
||||
|
||||
def components_are_flavor_compatible(component_items: list[dict]) -> bool:
|
||||
flavors = {
|
||||
normalize_food_flavor(item.get("flavor_profile"), "neutral")
|
||||
for item in component_items
|
||||
if meaningful_component(item)
|
||||
}
|
||||
return not ({"sweet", "savory"} <= flavors)
|
||||
|
||||
|
||||
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
|
||||
if not components_are_flavor_compatible(component_items):
|
||||
return -999
|
||||
|
||||
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]
|
||||
@@ -848,22 +875,27 @@ def group_items_by_availability(items: list[dict]) -> list[dict]:
|
||||
return result
|
||||
|
||||
|
||||
def extract_item_form_data(existing: dict | None = None) -> dict:
|
||||
def extract_item_form_data(kind: str, 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)
|
||||
meal_type = normalize_meal_type(request.form.get("meal_type"), meal_type_default)
|
||||
if kind == "meal":
|
||||
daypart_ids = daypart_ids_for_meal_type(meal_type)
|
||||
component_ids = list(dict.fromkeys(int(value) for value in request.form.getlist("component_ids") if value.isdigit()))
|
||||
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")),
|
||||
"flavor_profile": normalize_food_flavor(request.form.get("flavor_profile"), form_data.get("flavor_profile", "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_type": meal_type,
|
||||
"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(),
|
||||
@@ -872,13 +904,17 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
|
||||
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
|
||||
"food_search": request.form.get("food_search", "").strip(),
|
||||
"daypart_ids": daypart_ids,
|
||||
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
|
||||
"component_ids": component_ids,
|
||||
"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_flavor_profile": normalize_food_flavor(
|
||||
request.form.get("quick_food_flavor_profile"),
|
||||
form_data.get("quick_food_flavor_profile", "neutral"),
|
||||
),
|
||||
"quick_food_role": normalize_food_role(
|
||||
request.form.get("quick_food_role"),
|
||||
form_data.get("quick_food_role", "base"),
|
||||
@@ -899,9 +935,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, base_type, suggestion_role, suggestion_priority, can_be_meal_core, energy_density, note, created_by, updated_by
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, flavor_profile, suggestion_role, suggestion_priority, can_be_meal_core, energy_density, note, created_by, updated_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
@@ -911,6 +947,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
||||
form_data["quick_food_name"],
|
||||
form_data["quick_food_category"],
|
||||
form_data["quick_food_base_type"],
|
||||
form_data["quick_food_flavor_profile"],
|
||||
form_data["quick_food_role"],
|
||||
form_data["quick_food_priority"],
|
||||
1 if form_data["quick_food_can_be_meal_core"] else 0,
|
||||
@@ -1207,6 +1244,35 @@ def sync_meal_components(meal_id: int, food_ids: list[int]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def fetch_items_by_ids(item_ids: list[int]) -> list[dict]:
|
||||
normalized_ids = list(dict.fromkeys(int(item_id) for item_id in item_ids if int(item_id) > 0))
|
||||
if not normalized_ids:
|
||||
return []
|
||||
|
||||
placeholders = ", ".join("?" for _ in normalized_ids)
|
||||
rows = get_db().execute(
|
||||
f"""
|
||||
SELECT items.*,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username,
|
||||
target.display_name AS target_display_name,
|
||||
target.username AS target_username,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM shopping_entries
|
||||
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
|
||||
) AS is_on_shopping_list
|
||||
FROM items
|
||||
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
|
||||
LEFT JOIN users AS target ON target.id = items.target_user_id
|
||||
WHERE items.id IN ({placeholders}) AND {visible_clause('items')}
|
||||
""",
|
||||
[*normalized_ids, *visible_params()],
|
||||
).fetchall()
|
||||
items_by_id = {item["id"]: item for item in decorate_items(rows)}
|
||||
return [items_by_id[item_id] for item_id in normalized_ids if item_id in items_by_id]
|
||||
|
||||
|
||||
def fetch_shopping_entries():
|
||||
rows = get_db().execute(
|
||||
f"""
|
||||
@@ -1416,7 +1482,12 @@ def fetch_plan_candidates(daypart_id: int, query: str | None = None):
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
return decorate_items(rows)
|
||||
items = decorate_items(rows)
|
||||
return [
|
||||
item
|
||||
for item in items
|
||||
if item["kind"] != "meal" or meal_matches_daypart(item, daypart_id)
|
||||
]
|
||||
|
||||
|
||||
def fetch_home_food_ids() -> set[int]:
|
||||
@@ -1445,6 +1516,15 @@ def meal_type_for_daypart(daypart_id: int | None) -> str:
|
||||
return DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack")
|
||||
|
||||
|
||||
def daypart_ids_for_meal_type(meal_type: str | None) -> list[int]:
|
||||
normalized_type = normalize_meal_type(meal_type, "snack")
|
||||
return [
|
||||
int(daypart["id"])
|
||||
for daypart in get_dayparts()
|
||||
if DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack") == normalized_type
|
||||
]
|
||||
|
||||
|
||||
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 ""
|
||||
@@ -1486,6 +1566,22 @@ def item_matches_daypart(item: dict, daypart_id: int | None) -> bool:
|
||||
return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta)
|
||||
|
||||
|
||||
def meal_matches_daypart(item: dict, daypart_id: int | None) -> bool:
|
||||
if item.get("kind") != "meal" or daypart_id is None:
|
||||
return True
|
||||
|
||||
dayparts_meta = item.get("dayparts_meta") or []
|
||||
if dayparts_meta:
|
||||
return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta)
|
||||
|
||||
raw_meal_type = (item.get("meal_type") or "").strip()
|
||||
if not raw_meal_type:
|
||||
return False
|
||||
|
||||
expected_meal_type = meal_type_for_daypart(daypart_id)
|
||||
return normalize_meal_type(raw_meal_type, "snack") == expected_meal_type
|
||||
|
||||
|
||||
def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]:
|
||||
return tuple(sorted({int(component_id) for component_id in component_ids}))
|
||||
|
||||
@@ -1522,17 +1618,17 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1542,22 +1638,22 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}},
|
||||
{"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1566,24 +1662,24 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
{"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False, "flavors": {"savory", "neutral"}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"reason": "Schnell und alltagstauglich",
|
||||
"slots": [
|
||||
{"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True},
|
||||
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
|
||||
{"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True, "flavors": {"savory", "neutral"}},
|
||||
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1629,6 +1725,8 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
||||
continue
|
||||
seen_signatures.add(signature)
|
||||
combo_items = list(combo)
|
||||
if not components_are_flavor_compatible(combo_items):
|
||||
continue
|
||||
suggestions.append(
|
||||
{
|
||||
"title": build_generated_meal_name(combo_items, daypart_slug),
|
||||
@@ -1685,6 +1783,8 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
|
||||
continue
|
||||
|
||||
component_items = [visible_food_map[component_id] for component_id in component_ids]
|
||||
if not components_are_flavor_compatible(component_items):
|
||||
continue
|
||||
available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map]
|
||||
missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids]
|
||||
|
||||
@@ -2806,12 +2906,16 @@ def sync_item_set_items(set_id: int, item_ids: list[int]) -> None:
|
||||
|
||||
def extract_item_set_form_data(existing: dict | None = None) -> dict:
|
||||
form_data = existing or {}
|
||||
item_ids = [int(value) for value in request.form.getlist("item_ids") if value.isdigit()]
|
||||
remove_item_id = request.form.get("remove_item_id", "").strip()
|
||||
if remove_item_id.isdigit():
|
||||
item_ids = [item_id for item_id in item_ids if item_id != int(remove_item_id)]
|
||||
form_data.update(
|
||||
{
|
||||
"name": request.form.get("name", "").strip(),
|
||||
"description": request.form.get("description", "").strip(),
|
||||
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
|
||||
"item_ids": [int(value) for value in request.form.getlist("item_ids") if value.isdigit()],
|
||||
"item_ids": item_ids,
|
||||
"item_search": request.form.get("item_search", "").strip(),
|
||||
}
|
||||
)
|
||||
@@ -2846,11 +2950,14 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict):
|
||||
item=item,
|
||||
dayparts=get_dayparts(),
|
||||
food_groups=group_items_by_availability(foods),
|
||||
selected_components=fetch_items_by_ids(form_data.get("component_ids", [])) if kind == "meal" else [],
|
||||
categories=get_category_options(
|
||||
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_flavor_options=FOOD_FLAVOR_OPTIONS,
|
||||
food_flavor_descriptions=FOOD_FLAVOR_DESCRIPTIONS,
|
||||
food_role_options=FOOD_ROLE_OPTIONS,
|
||||
food_role_descriptions=FOOD_ROLE_DESCRIPTIONS,
|
||||
suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS,
|
||||
@@ -3338,6 +3445,7 @@ def item_set_create():
|
||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||
name_suggestions=ITEM_SET_NAME_SUGGESTIONS,
|
||||
item_groups=group_items_by_availability(items),
|
||||
selected_items=fetch_items_by_ids(form_data["item_ids"]),
|
||||
)
|
||||
|
||||
|
||||
@@ -3388,6 +3496,7 @@ def item_set_edit(set_id: int):
|
||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||
name_suggestions=ITEM_SET_NAME_SUGGESTIONS,
|
||||
item_groups=group_items_by_availability(items),
|
||||
selected_items=fetch_items_by_ids(form_data["item_ids"]),
|
||||
)
|
||||
|
||||
|
||||
@@ -3684,6 +3793,7 @@ def item_create(kind: str):
|
||||
"name": request.args.get("name", "").strip(),
|
||||
"category": "",
|
||||
"base_type": "neutral",
|
||||
"flavor_profile": "neutral",
|
||||
"suggestion_role": "base",
|
||||
"suggestion_priority": "normal",
|
||||
"can_be_meal_core": False,
|
||||
@@ -3700,6 +3810,7 @@ def item_create(kind: str):
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_base_type": "neutral",
|
||||
"quick_food_flavor_profile": "neutral",
|
||||
"quick_food_role": "base",
|
||||
"quick_food_priority": "normal",
|
||||
"quick_food_can_be_meal_core": False,
|
||||
@@ -3709,7 +3820,16 @@ def item_create(kind: str):
|
||||
|
||||
if request.method == "POST":
|
||||
form_action = request.form.get("form_action", "save_item")
|
||||
form_data = extract_item_form_data(form_data)
|
||||
form_data = extract_item_form_data(kind, form_data)
|
||||
|
||||
if kind == "meal" and request.form.get("remove_component_id", "").isdigit():
|
||||
remove_component_id = int(request.form.get("remove_component_id", "0"))
|
||||
form_data["component_ids"] = [
|
||||
component_id
|
||||
for component_id in form_data["component_ids"]
|
||||
if component_id != remove_component_id
|
||||
]
|
||||
return render_item_form(kind, item=None, form_data=form_data)
|
||||
|
||||
if kind == "meal" and form_action == "filter_foods":
|
||||
return render_item_form(kind, item=None, form_data=form_data)
|
||||
@@ -3742,9 +3862,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, base_type, suggestion_role, suggestion_priority, can_be_meal_core, meal_type, meal_tags, energy_density, note, photo_filename, created_by, updated_by
|
||||
household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, flavor_profile, 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(),
|
||||
@@ -3755,6 +3875,7 @@ def item_create(kind: str):
|
||||
form_data["name"],
|
||||
form_data["category"] if kind == "food" else None,
|
||||
form_data["base_type"] if kind == "food" else "neutral",
|
||||
form_data["flavor_profile"] 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,
|
||||
@@ -3793,11 +3914,12 @@ def item_edit(item_id: int):
|
||||
"name": item["name"],
|
||||
"category": item["category"] or "",
|
||||
"base_type": item.get("base_type") or "neutral",
|
||||
"flavor_profile": item.get("flavor_profile") 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")),
|
||||
"meal_tags": normalize_meal_tags(item.get("meal_tags")),
|
||||
"energy_density": item.get("energy_density") or "neutral",
|
||||
"note": item["note"] or "",
|
||||
"visibility": item["visibility"],
|
||||
@@ -3809,6 +3931,7 @@ def item_edit(item_id: int):
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_base_type": "neutral",
|
||||
"quick_food_flavor_profile": "neutral",
|
||||
"quick_food_role": "base",
|
||||
"quick_food_priority": "normal",
|
||||
"quick_food_can_be_meal_core": False,
|
||||
@@ -3818,7 +3941,16 @@ def item_edit(item_id: int):
|
||||
|
||||
if request.method == "POST":
|
||||
form_action = request.form.get("form_action", "save_item")
|
||||
form_data = extract_item_form_data(form_data)
|
||||
form_data = extract_item_form_data(item["kind"], form_data)
|
||||
|
||||
if item["kind"] == "meal" and request.form.get("remove_component_id", "").isdigit():
|
||||
remove_component_id = int(request.form.get("remove_component_id", "0"))
|
||||
form_data["component_ids"] = [
|
||||
component_id
|
||||
for component_id in form_data["component_ids"]
|
||||
if component_id != remove_component_id
|
||||
]
|
||||
return render_item_form(item["kind"], item=item, form_data=form_data)
|
||||
|
||||
if item["kind"] == "meal" and form_action == "filter_foods":
|
||||
return render_item_form(item["kind"], item=item, form_data=form_data)
|
||||
@@ -3854,6 +3986,7 @@ def item_edit(item_id: int):
|
||||
SET name = ?,
|
||||
category = ?,
|
||||
base_type = ?,
|
||||
flavor_profile = ?,
|
||||
suggestion_role = ?,
|
||||
suggestion_priority = ?,
|
||||
can_be_meal_core = ?,
|
||||
@@ -3872,6 +4005,7 @@ def item_edit(item_id: int):
|
||||
form_data["name"],
|
||||
form_data["category"] if item["kind"] == "food" else None,
|
||||
form_data["base_type"] if item["kind"] == "food" else "neutral",
|
||||
form_data["flavor_profile"] 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,
|
||||
|
||||
@@ -122,6 +122,7 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
name TEXT NOT NULL,
|
||||
category TEXT,
|
||||
base_type TEXT NOT NULL DEFAULT 'neutral',
|
||||
flavor_profile 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,
|
||||
|
||||
@@ -653,6 +653,255 @@ h3 {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.selected-component-stack {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.selected-components-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.selected-component-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
min-height: 138px;
|
||||
padding: 0.9rem 0.85rem 0.8rem;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--accent-soft) 10%, var(--surface-strong) 90%);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.selected-component-main {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.selected-component-main strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.selected-component-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 4.4rem;
|
||||
}
|
||||
|
||||
.selected-component-visual img,
|
||||
.selected-component-fallback {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.selected-component-visual img {
|
||||
object-fit: cover;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--accent) 10%, transparent 90%);
|
||||
}
|
||||
|
||||
.selected-component-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--surface) 76%, #fff 24%);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||
color: color-mix(in srgb, var(--accent-strong) 70%, var(--text) 30%);
|
||||
}
|
||||
|
||||
.selected-component-fallback .ui-icon {
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
}
|
||||
|
||||
.selected-component-remove {
|
||||
position: absolute;
|
||||
top: 0.45rem;
|
||||
right: 0.45rem;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
min-width: 1.9rem;
|
||||
padding: 0;
|
||||
border-radius: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 74%, transparent 26%);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.selected-component-remove:hover {
|
||||
background: color-mix(in srgb, var(--accent-soft) 40%, transparent 60%);
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--line) 60%);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.selected-component-remove span[aria-hidden="true"] {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .selected-component-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 76%, #4d413d 24%),
|
||||
color-mix(in srgb, var(--surface) 94%, #2c2523 6%)
|
||||
);
|
||||
border-color: color-mix(in srgb, rgba(243, 177, 125, 0.36) 48%, var(--line) 52%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .selected-component-fallback {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 72%, #4a403c 28%),
|
||||
color-mix(in srgb, var(--surface-strong) 86%, #241f1d 14%)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--line) 54%, rgba(243, 177, 125, 0.18) 46%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .selected-component-remove {
|
||||
background: rgba(32, 27, 25, 0.16);
|
||||
color: color-mix(in srgb, var(--muted) 90%, white 10%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .selected-component-remove:hover {
|
||||
background: color-mix(in srgb, var(--accent-soft) 42%, rgba(32, 27, 25, 0.58) 58%);
|
||||
}
|
||||
|
||||
.package-option-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.set-item-option {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.set-item-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.set-item-option-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
min-height: 138px;
|
||||
padding: 0.9rem 0.85rem 0.8rem;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--accent-soft) 10%, var(--surface-strong) 90%);
|
||||
color: var(--muted);
|
||||
align-content: start;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.set-item-option-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 4.4rem;
|
||||
}
|
||||
|
||||
.set-item-option-visual img,
|
||||
.set-item-option-fallback {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.set-item-option-visual img {
|
||||
object-fit: cover;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--accent) 10%, transparent 90%);
|
||||
}
|
||||
|
||||
.set-item-option-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--surface) 76%, #fff 24%);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||
color: color-mix(in srgb, var(--accent-strong) 70%, var(--text) 30%);
|
||||
}
|
||||
|
||||
.set-item-option-fallback .ui-icon {
|
||||
width: 1.45rem;
|
||||
height: 1.45rem;
|
||||
}
|
||||
|
||||
.set-item-option-label {
|
||||
margin-top: auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.set-item-option:hover .set-item-option-card {
|
||||
border-color: var(--accent-soft);
|
||||
transform: translateY(-1px);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.set-item-option input:focus-visible + .set-item-option-card {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent 55%);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.set-item-option input:checked + .set-item-option-card {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--line) 45%);
|
||||
background: color-mix(in srgb, var(--accent-soft) 18%, var(--surface-strong) 82%);
|
||||
box-shadow: 0 12px 30px color-mix(in srgb, var(--accent) 14%, transparent 86%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.set-item-option input:checked + .set-item-option-card .set-item-option-fallback {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--surface) 84%);
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--line) 62%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .set-item-option-card {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 76%, #4d413d 24%),
|
||||
color-mix(in srgb, var(--surface) 94%, #2c2523 6%)
|
||||
);
|
||||
border-color: color-mix(in srgb, rgba(243, 177, 125, 0.36) 48%, var(--line) 52%);
|
||||
color: color-mix(in srgb, var(--muted) 92%, white 8%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .set-item-option-fallback {
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 72%, #4a403c 28%),
|
||||
color-mix(in srgb, var(--surface-strong) 86%, #241f1d 14%)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--line) 54%, rgba(243, 177, 125, 0.18) 46%);
|
||||
}
|
||||
|
||||
.quick-food-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -732,12 +981,82 @@ h3 {
|
||||
|
||||
.item-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.item-body p {
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.item-meta-disclosure {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.05rem;
|
||||
}
|
||||
|
||||
.item-meta-disclosure > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
width: fit-content;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 78%, transparent 22%);
|
||||
background: color-mix(in srgb, var(--surface-soft) 38%, transparent 62%);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.item-meta-disclosure > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item-meta-disclosure > summary:hover {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--accent) 32%, var(--line) 68%);
|
||||
background: color-mix(in srgb, var(--accent-soft) 22%, var(--surface-soft) 78%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.item-meta-disclosure[open] > summary {
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--accent) 36%, var(--line) 64%);
|
||||
background: color-mix(in srgb, var(--accent-soft) 26%, var(--surface-soft) 74%);
|
||||
}
|
||||
|
||||
.item-meta-panel {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
padding: 0.9rem 1rem 0.95rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 82%, transparent 18%);
|
||||
background: color-mix(in srgb, var(--surface-soft) 46%, transparent 54%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .item-meta-disclosure > summary {
|
||||
background: color-mix(in srgb, var(--surface-soft) 42%, rgba(33, 28, 27, 0.58) 58%);
|
||||
border-color: color-mix(in srgb, var(--line) 62%, rgba(243, 177, 125, 0.12) 38%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .item-meta-disclosure > summary:hover,
|
||||
[data-theme="dark"] .item-meta-disclosure[open] > summary {
|
||||
background: color-mix(in srgb, var(--accent-soft) 24%, rgba(38, 31, 29, 0.76) 76%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .item-meta-panel {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 60%, #463c39 40%),
|
||||
color-mix(in srgb, var(--surface) 92%, #2a2422 8%)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
@@ -773,6 +1092,7 @@ h3 {
|
||||
.stack-form,
|
||||
.stack-sections,
|
||||
.planner-day-stack,
|
||||
.planner-day-sidebar,
|
||||
.planner-entry-list,
|
||||
.week-entry-stack,
|
||||
.week-slot-stack {
|
||||
@@ -780,6 +1100,18 @@ h3 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.planner-day-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.planner-day-main,
|
||||
.planner-day-sidebar {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-spaced-panel > .panel-head + * {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
@@ -2187,6 +2519,46 @@ legend {
|
||||
mask-image: url("../icons/fa/leaf.svg");
|
||||
}
|
||||
|
||||
.icon-component-protein {
|
||||
-webkit-mask-image: url("../icons/components/protein.svg");
|
||||
mask-image: url("../icons/components/protein.svg");
|
||||
}
|
||||
|
||||
.icon-component-carb {
|
||||
-webkit-mask-image: url("../icons/components/carb.svg");
|
||||
mask-image: url("../icons/components/carb.svg");
|
||||
}
|
||||
|
||||
.icon-component-veg {
|
||||
-webkit-mask-image: url("../icons/components/veg.svg");
|
||||
mask-image: url("../icons/components/veg.svg");
|
||||
}
|
||||
|
||||
.icon-component-fruit {
|
||||
-webkit-mask-image: url("../icons/components/fruit.svg");
|
||||
mask-image: url("../icons/components/fruit.svg");
|
||||
}
|
||||
|
||||
.icon-component-dairy {
|
||||
-webkit-mask-image: url("../icons/components/dairy.svg");
|
||||
mask-image: url("../icons/components/dairy.svg");
|
||||
}
|
||||
|
||||
.icon-component-nuts {
|
||||
-webkit-mask-image: url("../icons/components/nuts.svg");
|
||||
mask-image: url("../icons/components/nuts.svg");
|
||||
}
|
||||
|
||||
.icon-component-seeds {
|
||||
-webkit-mask-image: url("../icons/components/seeds.svg");
|
||||
mask-image: url("../icons/components/seeds.svg");
|
||||
}
|
||||
|
||||
.icon-component-neutral {
|
||||
-webkit-mask-image: url("../icons/components/neutral.svg");
|
||||
mask-image: url("../icons/components/neutral.svg");
|
||||
}
|
||||
|
||||
.mobile-sheet-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -2269,6 +2641,7 @@ legend {
|
||||
|
||||
.stats-grid,
|
||||
.two-column,
|
||||
.planner-day-layout,
|
||||
.template-library-grid,
|
||||
.settings-grid,
|
||||
.inline-form,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M240 112L304 112L304 144L240 144L240 112zM256 176L304 176L304 208L240 208L240 176L256 176zM112 240L160 240L160 272L96 272L96 240L112 240zM192 495.8L192 512L448 512L448 475.6L466.3 466.9C510 446.1 540.9 402.8 543.8 352L96.2 352C99.1 402.8 130 446.2 173.7 466.9L192 475.6L192 495.8zM160 495.8C105.5 469.9 67.2 415.6 64.2 352C64.1 349.4 64 346.7 64 344L64 320L576 320L576 344C576 346.7 575.9 349.4 575.8 352C572.8 415.6 534.5 469.9 480 495.8L480 544L160 544L160 495.8zM288 240L352 240L352 272L288 272L288 240zM192 240L256 240L256 272L192 272L192 240zM160 176L208 176L208 208L144 208L144 176L160 176zM384 240L448 240L448 272L384 272L384 240zM352 176L400 176L400 208L336 208L336 176L352 176zM480 240L544 240L544 272L480 272L480 240zM448 176L496 176L496 208L432 208L432 176L448 176zM352 112L400 112L400 144L336 144L336 112L352 112z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M96 512L544 512L544 352L96 352L96 512zM116.1 320L544 320C544 217.5 463.7 133.8 362.6 128.3L116.1 320zM64 320L352 96C475.7 96 576 196.3 576 320L576 544L64 544L64 320z"/></svg>
|
||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M306.3 192L288 192L288 144C288 99.8 323.8 64 368 64L416 64L416 112C416 156.2 380.2 192 336 192L306.3 192zM368 96C341.5 96 320 117.5 320 144L320 160L336 160C362.5 160 384 138.5 384 112L384 96L368 96zM208 192L320 224L432 192C508.3 192 544 275.7 544 352C544 480 464 576 384 576L320 560L256 576C176 576 96 480 96 352C96 275.7 131.7 192 208 192zM328.8 254.8L320 257.3L311.2 254.8L203.9 224.2C181.6 225.5 164.1 237.9 150.7 260.1C136 284.4 127.9 318.3 127.9 352.1C127.9 409.6 145.9 458.7 171.5 492.9C196.4 526 226.2 542.6 252.3 544L312.1 529.1L319.9 527.2L327.7 529.1L387.5 544C413.6 542.6 443.5 526 468.3 492.9C493.9 458.7 511.9 409.6 511.9 352.1C511.9 318.3 503.9 284.4 489.1 260.1C475.7 238 458.2 225.6 435.9 224.2L328.8 254.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 991 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M267.8 146.2L260 162.3L244.9 152.7C236.1 147.1 226 144 215.1 144C187.3 144 163 165.2 157.3 195L154.8 208.4L141.2 208C140.8 208 140.5 208 140.1 208C116.8 208 96.1 228.5 96.1 256L96.1 272L64.1 272L64.1 256C64 216.6 91.5 182.5 128.9 176.8C141 139.6 174.5 112 215 112C226.4 112 237.3 114.2 247.3 118.2C263.8 95.2 290 80 320 80C350 80 376.2 95.2 392.7 118.2C402.7 114.2 413.6 112 425 112C465.5 112 499 139.6 511.1 176.8C548.5 182.5 576 216.6 576 256L576 272L544 272L544 256C544 228.5 523.3 208 500 208C499.6 208 499.3 208 498.9 208L485.3 208.4L482.8 195C477.1 165.2 452.8 144 425 144C414.2 144 404 147.2 395.2 152.7L380.1 162.3L372.3 146.2C362.2 125.5 342.2 112 320 112C297.8 112 277.9 125.5 267.8 146.2zM192 495.8L192 512L448 512L448 475.6L466.3 466.9C510 446.1 540.9 402.8 543.8 352L96.2 352C99.1 402.8 130 446.2 173.7 466.9L192 475.6L192 495.8zM160 495.8C105.5 469.9 67.2 415.6 64.2 352C64.1 349.4 64 346.7 64 344L64 320L576 320L576 344C576 346.7 575.9 349.4 575.8 352C572.8 415.6 534.5 469.9 480 495.8L480 544L160 544L160 495.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M256 256L202.8 264.9C174.8 269.6 148.5 281.6 126.7 299.8L121.6 304C85.1 334.4 64 379.5 64 426.9L64 576L205.7 576C248.1 576 288.8 559.1 318.8 529.1L331.7 516.2C355.3 492.6 370.9 462.3 376.4 429.4L384 384L424.4 377.3C460.6 371.3 494 354.1 519.9 328.1L524.5 323.5C557.5 290.5 576 245.7 576 199L576 64L440.9 64C394.2 64 349.5 82.5 316.4 115.5L311.8 120.1C285.9 146 268.7 179.4 262.6 215.6L256 256zM544 96L544 199.1C544 237.3 528.8 273.9 501.8 300.9L497.2 305.5C476 326.7 448.7 340.8 419 345.7C389.1 350.7 368.1 354.2 356.1 356.2C354.2 367.4 350.5 390.1 344.8 424.2C340.4 450.5 327.9 474.8 309.1 493.7L296.2 506.6C272.2 530.6 239.6 544.1 205.7 544.1L96 544L96 426.9C96 388.9 112.9 352.9 142.1 328.6L147.2 324.4C164.7 309.8 185.7 300.2 208.1 296.5C248.7 289.7 273.9 285.5 283.9 283.9C285.9 271.8 289.4 250.9 294.4 221C299.3 191.4 313.4 164.1 334.6 142.8L339.2 138.2C366.2 111.2 402.8 96 441 96L544 96zM211.2 480C211.2 469.4 202.6 460.8 192 460.8C181.4 460.8 172.8 469.4 172.8 480C172.8 490.6 181.4 499.2 192 499.2C202.6 499.2 211.2 490.6 211.2 480zM416 275.2C426.6 275.2 435.2 266.6 435.2 256C435.2 245.4 426.6 236.8 416 236.8C405.4 236.8 396.8 245.4 396.8 256C396.8 266.6 405.4 275.2 416 275.2zM275.2 480C275.2 469.4 266.6 460.8 256 460.8C245.4 460.8 236.8 469.4 236.8 480C236.8 490.6 245.4 499.2 256 499.2C266.6 499.2 275.2 490.6 275.2 480zM480 275.2C490.6 275.2 499.2 266.6 499.2 256C499.2 245.4 490.6 236.8 480 236.8C469.4 236.8 460.8 245.4 460.8 256C460.8 266.6 469.4 275.2 480 275.2zM275.2 416C275.2 405.4 266.6 396.8 256 396.8C245.4 396.8 236.8 405.4 236.8 416C236.8 426.6 245.4 435.2 256 435.2C266.6 435.2 275.2 426.6 275.2 416zM480 211.2C490.6 211.2 499.2 202.6 499.2 192C499.2 181.4 490.6 172.8 480 172.8C469.4 172.8 460.8 181.4 460.8 192C460.8 202.6 469.4 211.2 480 211.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M301.3 384L400 384C479.5 384 544 319.5 544 240C544 160.5 479.5 96 400 96C320.5 96 256 160.5 256 240L256 338.7L301.3 384zM224 345.4L224 240C224 142.8 302.8 64 400 64C497.2 64 576 142.8 576 240C576 337.2 497.2 416 400 416L294.6 416L254.1 456.5C265.2 469.2 272 485.8 272 504C272 543.8 239.8 576 200 576C162.8 576 132.2 547.8 128.4 511.6C92.2 507.8 64 477.2 64 440C64 400.2 96.2 368 136 368C154.2 368 170.8 374.8 183.5 385.9L224 345.4zM243.3 371.3C204.6 410 183.6 431 180.2 434.4L169.4 418C162.2 407.1 149.9 400 136 400C113.9 400 96 417.9 96 440C96 462.1 113.9 480 136 480L160 480L160 504C160 526.1 177.9 544 200 544C222.1 544 240 526.1 240 504C240 490 232.9 477.8 222 470.6L205.6 459.8C209 456.4 230 435.4 268.7 396.7L243.3 371.3z"/></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M576 64L576 112L575.7 123.5C569.9 238 478 329.9 363.5 335.7L352 336L352 576L320 576L320 384L288 384L276.5 383.7C158.1 377.7 64 279.8 64 160L64 128L128 128L139.5 128.3C219.3 132.3 287.9 178.1 324.3 244.2C344.7 141.4 435.3 64 544 64L576 64zM96 160C96 266 182 352 288 352L320 352C320 246 234 160 128 160L96 160zM544 96C438 96 352 182 352 288L352 304C458 304 544 218 544 112L544 96z"/></svg>
|
||||
|
After Width: | Height: | Size: 646 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M435.4 44.7L424.1 33.4C418.6 38.9 407 50.5 389.4 68.1C363.5 94 355.5 130.9 365.3 163.7C354.7 161.3 343.7 160 332.4 160C273.3 160 219.9 195.2 196.6 249.5L82.7 515.4C74.7 534 64.2 558.5 51.2 588.9C79.9 576.6 175.4 535.7 390.6 443.4C444.9 420.1 480.1 366.7 480.1 307.6C480.1 296.3 478.8 285.3 476.4 274.7C509.1 284.5 546.1 276.5 572 250.6C589.6 233 601.2 221.4 606.7 215.9C601.2 210.4 589.6 198.8 572 181.2C548.4 157.6 515.5 148.8 485.1 155C491.2 124.5 482.5 91.7 458.9 68.1L435.4 44.7zM458.8 203.9L458.8 203.9C483.8 178.9 524.3 178.9 549.3 203.9L561.4 216L549.3 228.1C524.3 253.1 483.8 253.1 458.8 228.1L446.7 216L458.8 203.9zM436.5 181L424.1 193.4L412 181.3C387 156.3 387 115.8 412 90.8L424.1 78.7L436.2 90.8C461.1 115.7 461.2 156 436.5 181zM332.4 192C396.3 192 448.1 243.8 448.1 307.7C448.1 354 420.5 395.8 378 414.1L284.7 454.1C284.3 453.6 283.9 453.2 283.5 452.7C258.7 427.9 244.3 413.5 240.2 409.4L217.5 432C222.9 437.4 234.7 449.2 253.1 467.6L112.1 528L226.1 262.1C227.4 259 228.9 255.9 230.5 253C267.2 289.7 286.4 308.9 288.2 310.7L310.8 288.1L251.5 228.8C250.9 228.2 250.2 227.6 249.5 227.1C270.9 205.1 300.6 192.1 332.4 192.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -4,7 +4,15 @@
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
||||
<h1>
|
||||
{% if item and kind == 'meal' %}
|
||||
{{ item.name }}
|
||||
{% elif item %}
|
||||
{{ item.name }} bearbeiten
|
||||
{% else %}
|
||||
Neue {{ item_kind_singular_labels[kind] }}
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="lead">
|
||||
{% if kind == 'food' %}
|
||||
Name, Sichtbarkeit und ein paar ruhige Hinweise dazu, wie ein Lebensmittel in Vorschlägen gut passt.
|
||||
@@ -52,15 +60,27 @@
|
||||
</div>
|
||||
|
||||
{% if kind == 'food' %}
|
||||
<label>
|
||||
Baustein
|
||||
<select name="base_type">
|
||||
{% for value, label in builder_options %}
|
||||
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">{{ builder_descriptions[form_data.base_type] }}</small>
|
||||
</label>
|
||||
<div class="dual-grid">
|
||||
<label>
|
||||
Baustein
|
||||
<select name="base_type">
|
||||
{% for value, label in builder_options %}
|
||||
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">{{ builder_descriptions[form_data.base_type] }}</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Geschmacksrichtung
|
||||
<select name="flavor_profile">
|
||||
{% for value, label in food_flavor_options %}
|
||||
<option value="{{ value }}" {% if form_data.flavor_profile == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">{{ food_flavor_descriptions[form_data.flavor_profile] }}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dual-grid">
|
||||
<label>
|
||||
@@ -162,27 +182,72 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Passende Tageszeiten</legend>
|
||||
<div class="checkbox-grid daypart-option-grid">
|
||||
{% for daypart in dayparts %}
|
||||
<label class="daypart-option">
|
||||
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||
<span class="daypart-option-card">
|
||||
<span class="daypart-option-icon">
|
||||
<span class="ui-icon {{ daypart_icon_class(daypart.slug) }}"></span>
|
||||
{% if kind == 'food' %}
|
||||
<fieldset>
|
||||
<legend>Passende Tageszeiten</legend>
|
||||
<div class="checkbox-grid daypart-option-grid">
|
||||
{% for daypart in dayparts %}
|
||||
<label class="daypart-option">
|
||||
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||
<span class="daypart-option-card">
|
||||
<span class="daypart-option-icon">
|
||||
<span class="ui-icon {{ daypart_icon_class(daypart.slug) }}"></span>
|
||||
</span>
|
||||
<span class="daypart-option-label">{{ daypart.name }}</span>
|
||||
</span>
|
||||
<span class="daypart-option-label">{{ daypart.name }}</span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
{% if kind == 'meal' %}
|
||||
<fieldset>
|
||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||
<p class="muted">Du kannst eine Mahlzeitenidee frei benennen oder aus sichtbaren Lebensmitteln zusammensetzen. Nouri nutzt dabei später Grundtyp, Rolle und Tageszeit der Lebensmittel für ruhigere Vorschläge.</p>
|
||||
{% if selected_components %}
|
||||
<div class="selected-component-stack">
|
||||
<p class="helper-text">Schon ausgewählt</p>
|
||||
<div class="selected-components-grid">
|
||||
{% for component in selected_components %}
|
||||
{% set component_icon_class = {
|
||||
'protein': 'icon-component-protein',
|
||||
'carb': 'icon-component-carb',
|
||||
'veg': 'icon-component-veg',
|
||||
'fruit': 'icon-component-fruit',
|
||||
'dairy': 'icon-component-dairy',
|
||||
'nuts': 'icon-component-nuts',
|
||||
'seeds': 'icon-component-seeds',
|
||||
'neutral': 'icon-component-neutral',
|
||||
}.get(component.primary_builder_key or component.base_type, 'icon-component-neutral') %}
|
||||
<article class="selected-component-card">
|
||||
<input type="hidden" name="component_ids" value="{{ component.id }}">
|
||||
<button class="selected-component-remove" type="submit" name="remove_component_id" value="{{ component.id }}">
|
||||
<span aria-hidden="true">×</span>
|
||||
<span class="sr-only">{{ component.name }} entfernen</span>
|
||||
</button>
|
||||
<div class="selected-component-visual">
|
||||
{% if component.photo_filename %}
|
||||
<img
|
||||
src="{{ image_url(component.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(component.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ component.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<span class="selected-component-fallback">
|
||||
<span class="ui-icon {{ component_icon_class }}"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="selected-component-main">
|
||||
<strong>{{ component.name }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="inline-form">
|
||||
<label class="wide">
|
||||
Lebensmittel suchen
|
||||
@@ -239,6 +304,14 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Geschmacksrichtung
|
||||
<select name="quick_food_flavor_profile">
|
||||
{% for value, label in food_flavor_options %}
|
||||
<option value="{{ value }}" {% if form_data.quick_food_flavor_profile == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Rolle in Vorschlägen
|
||||
<select name="quick_food_role">
|
||||
|
||||
@@ -69,22 +69,45 @@
|
||||
<h2>{{ item.name }}</h2>
|
||||
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
||||
</div>
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ item.visibility_label }}</span>
|
||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||
<span class="chip">{{ item.for_label }}</span>
|
||||
</div>
|
||||
{% if item.kind == 'food' %}
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ item.base_type_label }}</span>
|
||||
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||
<span class="chip">{{ item.suggestion_priority_label }}</span>
|
||||
{% if item.can_be_meal_core %}
|
||||
<span class="chip status-okay">Trägt gut eine Mahlzeit</span>
|
||||
<span class="chip">{{ item.for_label }}</span>
|
||||
{% if item.is_on_shopping_list %}
|
||||
<span class="chip status-idea">Auf Einkaufsliste</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||
<details class="item-meta-disclosure">
|
||||
<summary>Mehr zeigen</summary>
|
||||
<div class="item-meta-panel">
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ item.visibility_label }}</span>
|
||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||
<span class="chip">{{ item.base_type_label }}</span>
|
||||
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||
<span class="chip">{{ item.suggestion_priority_label }}</span>
|
||||
{% if item.can_be_meal_core %}
|
||||
<span class="chip status-okay">Trägt gut eine Mahlzeit</span>
|
||||
{% endif %}
|
||||
<span class="chip">{{ item_kind_labels[item.kind] }}</span>
|
||||
</div>
|
||||
{% if item.dayparts %}
|
||||
<div class="chip-row">
|
||||
{% for daypart in item.dayparts %}
|
||||
<span class="chip">{{ daypart }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ item.visibility_label }}</span>
|
||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||
<span class="chip">{{ item.for_label }}</span>
|
||||
</div>
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ item.meal_type_label }}</span>
|
||||
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||
@@ -93,7 +116,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.dayparts %}
|
||||
{% if item.kind != 'food' and item.dayparts %}
|
||||
<div class="chip-row">
|
||||
{% for daypart in item.dayparts %}
|
||||
<span class="chip">{{ daypart }}</span>
|
||||
@@ -103,7 +126,7 @@
|
||||
{% if item.components %}
|
||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||
{% endif %}
|
||||
{% if item.note %}
|
||||
{% if item.kind != 'food' and item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,61 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if selected_items %}
|
||||
<fieldset>
|
||||
<legend>Schon ausgewählt</legend>
|
||||
{% for item_id in form_data.item_ids %}
|
||||
<input type="hidden" name="item_ids" value="{{ item_id }}">
|
||||
{% endfor %}
|
||||
<div class="selected-components-grid">
|
||||
{% for item in selected_items %}
|
||||
{% if item.kind == 'meal' %}
|
||||
{% set item_icon_class = {
|
||||
'breakfast': 'icon-daypart-breakfast',
|
||||
'lunch': 'icon-daypart-lunch',
|
||||
'dinner': 'icon-daypart-dinner',
|
||||
'snack': 'icon-daypart-afternoon-snack',
|
||||
}.get(item.meal_type, 'icon-utensils') %}
|
||||
{% else %}
|
||||
{% set item_icon_class = {
|
||||
'protein': 'icon-component-protein',
|
||||
'carb': 'icon-component-carb',
|
||||
'veg': 'icon-component-veg',
|
||||
'fruit': 'icon-component-fruit',
|
||||
'dairy': 'icon-component-dairy',
|
||||
'nuts': 'icon-component-nuts',
|
||||
'seeds': 'icon-component-seeds',
|
||||
'neutral': 'icon-component-neutral',
|
||||
}.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||
{% endif %}
|
||||
<article class="selected-component-card">
|
||||
<button class="selected-component-remove" type="submit" name="remove_item_id" value="{{ item.id }}">
|
||||
<span aria-hidden="true">×</span>
|
||||
<span class="sr-only">{{ item.name }} entfernen</span>
|
||||
</button>
|
||||
<div class="selected-component-visual">
|
||||
{% if item.photo_filename %}
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<span class="selected-component-fallback">
|
||||
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="selected-component-main">
|
||||
<strong>{{ item.name }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Einträge auswählen</legend>
|
||||
<label>
|
||||
@@ -51,11 +106,46 @@
|
||||
<h3>{{ group["title"] }}</h3>
|
||||
<span>{{ group["items"]|length }} Einträge</span>
|
||||
</div>
|
||||
<div class="checkbox-grid">
|
||||
<div class="checkbox-grid package-option-grid">
|
||||
{% for item in group["items"] %}
|
||||
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
||||
{% if item.kind == 'meal' %}
|
||||
{% set item_icon_class = {
|
||||
'breakfast': 'icon-daypart-breakfast',
|
||||
'lunch': 'icon-daypart-lunch',
|
||||
'dinner': 'icon-daypart-dinner',
|
||||
'snack': 'icon-daypart-afternoon-snack',
|
||||
}.get(item.meal_type, 'icon-utensils') %}
|
||||
{% else %}
|
||||
{% set item_icon_class = {
|
||||
'protein': 'icon-component-protein',
|
||||
'carb': 'icon-component-carb',
|
||||
'veg': 'icon-component-veg',
|
||||
'fruit': 'icon-component-fruit',
|
||||
'dairy': 'icon-component-dairy',
|
||||
'nuts': 'icon-component-nuts',
|
||||
'seeds': 'icon-component-seeds',
|
||||
'neutral': 'icon-component-neutral',
|
||||
}.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||
{% endif %}
|
||||
<label class="set-item-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
||||
<input type="checkbox" name="item_ids" value="{{ item.id }}" {% if item.id in form_data.item_ids %}checked{% endif %}>
|
||||
<span>{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
|
||||
<span class="set-item-option-card">
|
||||
<span class="set-item-option-visual">
|
||||
{% if item.photo_filename %}
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<span class="set-item-option-fallback">
|
||||
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="set-item-option-label">{{ item.name }}</span>
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -14,90 +14,53 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="two-column">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Tagesvorlagen</h2>
|
||||
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
|
||||
</div>
|
||||
{% if day_templates %}
|
||||
<div class="stack-sections">
|
||||
{% for template in day_templates %}
|
||||
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
|
||||
<div class="template-card">
|
||||
<strong>{{ template.name }}</strong>
|
||||
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
||||
</div>
|
||||
<button type="submit">Vorlage anwenden</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if day_hints %}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Heute im Blick</h2>
|
||||
</div>
|
||||
<div class="hint-list">
|
||||
{% for hint in day_hints %}
|
||||
<p class="hint-chip">{{ hint }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="planner-day-stack">
|
||||
{% set hidden_snack_sections = sections | selectattr('is_snack_daypart') | rejectattr('visible_by_default') | list %}
|
||||
{% if hidden_snack_sections %}
|
||||
<section class="panel compact-form-panel snack-reveal-panel" data-day-snack-actions>
|
||||
<div class="panel-head">
|
||||
<h2>Zwischenmahlzeit hinzufügen</h2>
|
||||
</div>
|
||||
<div class="chip-row snack-reveal-actions">
|
||||
{% for section in hidden_snack_sections %}
|
||||
<button
|
||||
class="ghost-button snack-reveal-button"
|
||||
type="button"
|
||||
data-day-snack-open
|
||||
data-target="#daypart-{{ section.daypart.id }}"
|
||||
>
|
||||
{{ section.daypart.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% for section in sections %}
|
||||
<details
|
||||
class="day-tile{% if section.entries %} has-entries{% endif %}{% if section.selected_quick_action %} has-selection{% endif %}"
|
||||
id="daypart-{{ section.daypart.id }}"
|
||||
{% if section.is_snack_daypart and not section.visible_by_default %}hidden data-day-snack-tile{% endif %}
|
||||
{% if section.is_open %}open{% endif %}
|
||||
>
|
||||
<summary class="day-tile-summary">
|
||||
<div class="day-tile-summary-main">
|
||||
<div class="day-tile-icon"><span class="ui-icon {{ daypart_icon_class(section.daypart.slug) }}"></span></div>
|
||||
<div>
|
||||
<h2>{{ section.daypart.name }}</h2>
|
||||
{% if section.summary_items %}
|
||||
<p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
|
||||
{% else %}
|
||||
<p class="muted">Noch offen. Öffnen, wenn du etwas eintragen möchtest.</p>
|
||||
{% endif %}
|
||||
<section class="planner-day-layout">
|
||||
<div class="planner-day-main">
|
||||
<section class="planner-day-stack">
|
||||
{% set hidden_snack_sections = sections | selectattr('is_snack_daypart') | rejectattr('visible_by_default') | list %}
|
||||
{% if hidden_snack_sections %}
|
||||
<section class="panel compact-form-panel snack-reveal-panel" data-day-snack-actions>
|
||||
<div class="panel-head">
|
||||
<h2>Zwischenmahlzeit hinzufügen</h2>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-pill{% if section.entries %} status-home{% endif %}">{{ section.entries|length }} geplant</span>
|
||||
</summary>
|
||||
<div class="chip-row snack-reveal-actions">
|
||||
{% for section in hidden_snack_sections %}
|
||||
<button
|
||||
class="ghost-button snack-reveal-button"
|
||||
type="button"
|
||||
data-day-snack-open
|
||||
data-target="#daypart-{{ section.daypart.id }}"
|
||||
>
|
||||
{{ section.daypart.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="day-tile-body">
|
||||
{% for section in sections %}
|
||||
<details
|
||||
class="day-tile{% if section.entries %} has-entries{% endif %}{% if section.selected_quick_action %} has-selection{% endif %}"
|
||||
id="daypart-{{ section.daypart.id }}"
|
||||
{% if section.is_snack_daypart and not section.visible_by_default %}hidden data-day-snack-tile{% endif %}
|
||||
{% if section.is_open %}open{% endif %}
|
||||
>
|
||||
<summary class="day-tile-summary">
|
||||
<div class="day-tile-summary-main">
|
||||
<div class="day-tile-icon"><span class="ui-icon {{ daypart_icon_class(section.daypart.slug) }}"></span></div>
|
||||
<div>
|
||||
<h2>{{ section.daypart.name }}</h2>
|
||||
{% if section.summary_items %}
|
||||
<p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
|
||||
{% else %}
|
||||
<p class="muted">Noch offen. Öffnen, wenn du etwas eintragen möchtest.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-pill{% if section.entries %} status-home{% endif %}">{{ section.entries|length }} geplant</span>
|
||||
</summary>
|
||||
|
||||
<div class="day-tile-body">
|
||||
{% if section.selected_quick_action %}
|
||||
<div class="suggestion-card selected-quick-action">
|
||||
<span class="status-pill status-home">Schon ausgewählt</span>
|
||||
@@ -301,8 +264,49 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="planner-day-sidebar">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Tagesvorlagen</h2>
|
||||
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% if day_templates %}
|
||||
<div class="stack-sections">
|
||||
{% for template in day_templates %}
|
||||
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
|
||||
<div class="template-card">
|
||||
<strong>{{ template.name }}</strong>
|
||||
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
||||
</div>
|
||||
<button type="submit">Vorlage anwenden</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if day_hints %}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Heute im Blick</h2>
|
||||
</div>
|
||||
<div class="hint-list">
|
||||
{% for hint in day_hints %}
|
||||
<p class="hint-chip">{{ hint }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
</aside>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||