Compare commits
9 Commits
V1.2.1
...
5a1c1d5c41
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a1c1d5c41 | |||
| f85ec81851 | |||
| bc31430a1e | |||
| 93793a456e | |||
| f17ab27c2e | |||
| a810162221 | |||
| 305440a6b2 | |||
| 6c7c1f01c9 | |||
| 7b751b4d47 |
@@ -4,8 +4,8 @@
|
|||||||
"author": "Florian Heinz",
|
"author": "Florian Heinz",
|
||||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
"tagline": "einfach essen planen",
|
"tagline": "einfach essen planen",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"upstreamVersion": "1.2.1",
|
"upstreamVersion": "1.2.2",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Nouri 1.2.2
|
||||||
|
|
||||||
|
Nouri 1.2.2 ist ein kleiner Pflege-Release auf Basis von 1.2.1. Der Schwerpunkt lag auf einer stimmigeren Oberfläche im Dark Mode und einer klareren Sprache in der neuen Lebensmittel-Logik.
|
||||||
|
|
||||||
|
## Neu in 1.2.2
|
||||||
|
|
||||||
|
- Die Builder-Bereiche in der Mahlzeitenansicht wirken im Dark Mode jetzt ruhiger und wärmer.
|
||||||
|
- Die hellgrauen Flächen bei `Zuhause`, `Merkliste` und dem direkten Anlegen neuer Lebensmittel wurden an die restliche Nouri-Oberfläche angepasst.
|
||||||
|
- Die Sprache im Lebensmittel-Formular wurde vereinfacht:
|
||||||
|
- `Passt eher zu` heißt jetzt `Baustein`
|
||||||
|
- `Kohlenhydratquelle` wird in der UI als `Kohlenhydrate` angezeigt
|
||||||
|
|
||||||
|
## Enthaltene Feinschliffe seit 1.2.1
|
||||||
|
|
||||||
|
- Neue Datenlogik für Lebensmittel und Vorschläge:
|
||||||
|
- Grundtyp, Rolle in Vorschlägen, Tageszeiten, Vorschlagsstärke und Mahlzeitenkern wurden klarer getrennt.
|
||||||
|
- Die Builder-Ansichten wurden mit kartenbasierten Auswahlfeldern und zusätzlichen Icons überarbeitet.
|
||||||
|
- Navigation und unterstützende Texte wurden weiter beruhigt und vereinheitlicht.
|
||||||
|
|
||||||
|
## Technisch
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.2.2`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.2.2` angehoben.
|
||||||
|
|
||||||
|
## Hinweis zum Update
|
||||||
|
|
||||||
|
- Beim Cloudron-Update sollte der `data`-Ordner weiterhin schreibbar sein, damit mögliche Datenbank-Updates sauber durchlaufen.
|
||||||
|
- Ein reguläres Cloudron-Update mit Backup ist für diesen Stand die sichere Variante.
|
||||||
@@ -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,
|
||||||
@@ -82,7 +93,7 @@ def load_app_version(root_dir: Path) -> str:
|
|||||||
).strip()
|
).strip()
|
||||||
if manifest_version:
|
if manifest_version:
|
||||||
return manifest_version
|
return manifest_version
|
||||||
return "1.2.1"
|
return "1.2.2"
|
||||||
|
|
||||||
|
|
||||||
def load_release_url() -> str:
|
def load_release_url() -> str:
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -36,7 +45,7 @@ DEFAULT_CATEGORY_BUILDERS = {
|
|||||||
|
|
||||||
BUILDER_LABELS = {
|
BUILDER_LABELS = {
|
||||||
"protein": "Proteinquelle",
|
"protein": "Proteinquelle",
|
||||||
"carb": "Kohlenhydratquelle",
|
"carb": "Kohlenhydrate",
|
||||||
"veg": "Gemüse / Ballaststoffquelle",
|
"veg": "Gemüse / Ballaststoffquelle",
|
||||||
"nuts": "Nüsse",
|
"nuts": "Nüsse",
|
||||||
"seeds": "Saaten",
|
"seeds": "Saaten",
|
||||||
@@ -58,6 +67,60 @@ 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_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",
|
||||||
|
"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 +137,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 = [
|
||||||
|
|||||||
@@ -8,9 +8,340 @@ 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.2"
|
||||||
|
|
||||||
|
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_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()
|
||||||
|
|
||||||
|
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 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:
|
def get_db() -> sqlite3.Connection:
|
||||||
@@ -129,6 +460,13 @@ 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", "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")
|
||||||
|
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 +592,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 +714,19 @@ 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", "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")
|
||||||
|
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 +769,8 @@ 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)
|
||||||
|
migrate_food_flavor_profiles(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO user_settings (user_id)
|
INSERT OR IGNORE INTO user_settings (user_id)
|
||||||
@@ -429,8 +778,15 @@ 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 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")
|
||||||
|
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")
|
||||||
|
|||||||
@@ -26,17 +26,33 @@ 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_FLAVOR_DESCRIPTIONS,
|
||||||
|
FOOD_FLAVOR_LABELS,
|
||||||
|
FOOD_FLAVOR_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 +212,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 +278,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 +301,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 +309,62 @@ 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_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
|
||||||
|
|
||||||
|
|
||||||
|
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 +434,25 @@ 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["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")
|
||||||
|
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 +604,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 +612,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 +620,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 +628,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 +644,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 +668,91 @@ 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()))
|
||||||
|
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 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:
|
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"])}
|
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]
|
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 +762,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 +775,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 +877,49 @@ 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")),
|
||||||
|
"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_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_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"),
|
||||||
|
),
|
||||||
|
"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 +931,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, flavor_profile, 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 +942,11 @@ 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_flavor_profile"],
|
||||||
|
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 +1471,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 +1549,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, "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, "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"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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, "flavors": {"sweet", "neutral"}},
|
||||||
|
{"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slots": ({"carb"}, {"dairy", "protein"}),
|
|
||||||
"reason": "Zuhause schnell kombinierbar",
|
"reason": "Zuhause schnell kombinierbar",
|
||||||
|
"slots": [
|
||||||
|
{"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"}},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"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, "flavors": {"sweet", "neutral"}},
|
||||||
]
|
{"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}},
|
||||||
else:
|
],
|
||||||
target_patterns = [
|
|
||||||
{
|
|
||||||
"slots": ({"protein"}, {"carb"}, {"veg"}),
|
|
||||||
"reason": "Zuhause als vollständige Mahlzeit möglich",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slots": ({"protein"}, {"carb"}),
|
|
||||||
"reason": "Lässt sich leicht ergänzen",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slots": ({"protein"}, {"veg"}),
|
|
||||||
"reason": "Zuhause schon gut kombinierbar",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slots": ({"carb"}, {"veg"}),
|
|
||||||
"reason": "Daraus kann schnell etwas Einfaches werden",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"reason": "Zuhause als vollständige Mahlzeit möglich",
|
||||||
|
"slots": [
|
||||||
|
{"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, "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, "flavors": {"savory", "neutral"}},
|
||||||
|
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
@@ -1371,6 +1662,8 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
|||||||
continue
|
continue
|
||||||
seen_signatures.add(signature)
|
seen_signatures.add(signature)
|
||||||
combo_items = list(combo)
|
combo_items = list(combo)
|
||||||
|
if not components_are_flavor_compatible(combo_items):
|
||||||
|
continue
|
||||||
suggestions.append(
|
suggestions.append(
|
||||||
{
|
{
|
||||||
"title": build_generated_meal_name(combo_items, daypart_slug),
|
"title": build_generated_meal_name(combo_items, daypart_slug),
|
||||||
@@ -1384,6 +1677,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:
|
||||||
@@ -1426,6 +1720,8 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
component_items = [visible_food_map[component_id] for component_id in component_ids]
|
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]
|
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]
|
missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids]
|
||||||
|
|
||||||
@@ -1511,14 +1807,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 +1834,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 +1905,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 +1915,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(
|
||||||
@@ -2033,7 +2340,7 @@ def fetch_plan_entries_for_range_export(start_date: date, end_date: date, *, mod
|
|||||||
|
|
||||||
|
|
||||||
def format_week_pdf_entry(entry: dict, *, mode: str) -> str:
|
def format_week_pdf_entry(entry: dict, *, mode: str) -> str:
|
||||||
label = entry["item_name"]
|
label = format_pdf_cell_label(entry["item_name"])
|
||||||
if mode == "household":
|
if mode == "household":
|
||||||
if entry.get("target_name"):
|
if entry.get("target_name"):
|
||||||
return f"{label} (Für {entry['target_name']})"
|
return f"{label} (Für {entry['target_name']})"
|
||||||
@@ -2046,6 +2353,23 @@ def format_week_pdf_entry(entry: dict, *, mode: str) -> str:
|
|||||||
return label
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def format_pdf_cell_label(label: str) -> str:
|
||||||
|
cleaned = " ".join((label or "").split())
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if " - " in cleaned:
|
||||||
|
return cleaned.replace(" - ", "\n")
|
||||||
|
|
||||||
|
if ", " in cleaned and len(cleaned) > 20:
|
||||||
|
return cleaned.replace(", ", ",\n")
|
||||||
|
|
||||||
|
if "-" in cleaned:
|
||||||
|
return "-\n".join(part for part in cleaned.split("-") if part)
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def build_week_pdf_rows(week_start: date, *, mode: str) -> tuple[list, list[list[str]]]:
|
def build_week_pdf_rows(week_start: date, *, mode: str) -> tuple[list, list[list[str]]]:
|
||||||
days = [week_start + timedelta(days=index) for index in range(7)]
|
days = [week_start + timedelta(days=index) for index in range(7)]
|
||||||
grouped_entries = fetch_plan_entries_for_range_export(week_start, week_start + timedelta(days=6), mode=mode)
|
grouped_entries = fetch_plan_entries_for_range_export(week_start, week_start + timedelta(days=6), mode=mode)
|
||||||
@@ -2098,6 +2422,7 @@ def build_week_plan_pdf(week_start: date, *, mode: str = "mine") -> bytes:
|
|||||||
pdf.cell(0, 6, f"KW {week_number:02d}", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 6, f"KW {week_number:02d}", new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.ln(3)
|
pdf.ln(3)
|
||||||
pdf.set_text_color(20, 20, 20)
|
pdf.set_text_color(20, 20, 20)
|
||||||
|
pdf.set_font("Helvetica", "", 10)
|
||||||
|
|
||||||
headings = [" "] + [f"{format_weekday(day)}\n{day.strftime('%d.%m.%Y')}" for day in days]
|
headings = [" "] + [f"{format_weekday(day)}\n{day.strftime('%d.%m.%Y')}" for day in days]
|
||||||
first_column_width = 34
|
first_column_width = 34
|
||||||
@@ -2562,6 +2887,14 @@ 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_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,
|
||||||
|
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(),
|
||||||
@@ -2616,27 +2949,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"],
|
||||||
),
|
),
|
||||||
@@ -3106,6 +3464,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
|
||||||
@@ -3114,6 +3473,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 = ?,
|
||||||
@@ -3139,6 +3499,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),
|
||||||
@@ -3361,6 +3722,13 @@ 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",
|
||||||
|
"flavor_profile": "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",
|
||||||
@@ -3371,6 +3739,11 @@ 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_flavor_profile": "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": "",
|
||||||
}
|
}
|
||||||
@@ -3410,9 +3783,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, 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(),
|
current_household_id(),
|
||||||
@@ -3421,7 +3794,14 @@ 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["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,
|
||||||
|
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,
|
||||||
@@ -3454,6 +3834,13 @@ 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",
|
||||||
|
"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")),
|
||||||
"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"],
|
||||||
@@ -3464,6 +3851,11 @@ 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_flavor_profile": "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": "",
|
||||||
}
|
}
|
||||||
@@ -3505,6 +3897,13 @@ def item_edit(item_id: int):
|
|||||||
UPDATE items
|
UPDATE items
|
||||||
SET name = ?,
|
SET name = ?,
|
||||||
category = ?,
|
category = ?,
|
||||||
|
base_type = ?,
|
||||||
|
flavor_profile = ?,
|
||||||
|
suggestion_role = ?,
|
||||||
|
suggestion_priority = ?,
|
||||||
|
can_be_meal_core = ?,
|
||||||
|
meal_type = ?,
|
||||||
|
meal_tags = ?,
|
||||||
energy_density = ?,
|
energy_density = ?,
|
||||||
note = ?,
|
note = ?,
|
||||||
visibility = ?,
|
visibility = ?,
|
||||||
@@ -3516,7 +3915,14 @@ 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["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,
|
||||||
|
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"],
|
||||||
@@ -3797,6 +4203,7 @@ def planner():
|
|||||||
week_hints=build_week_hints(week_start),
|
week_hints=build_week_hints(week_start),
|
||||||
upcoming_entries=fetch_upcoming_shopping_needs(limit=8),
|
upcoming_entries=fetch_upcoming_shopping_needs(limit=8),
|
||||||
household_settings=get_household_settings(),
|
household_settings=get_household_settings(),
|
||||||
|
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3923,6 +4330,7 @@ def planner_generated_meal():
|
|||||||
@login_required
|
@login_required
|
||||||
def planner_update(entry_id: int):
|
def planner_update(entry_id: int):
|
||||||
selected_date = parse_plan_date(request.form.get("plan_date"))
|
selected_date = parse_plan_date(request.form.get("plan_date"))
|
||||||
|
return_week = request.form.get("return_week", "").strip()
|
||||||
entry = get_db().execute(
|
entry = get_db().execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT plan_entries.*,
|
SELECT plan_entries.*,
|
||||||
@@ -3942,19 +4350,24 @@ def planner_update(entry_id: int):
|
|||||||
ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht bearbeiten.")
|
ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht bearbeiten.")
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
|
if return_week:
|
||||||
|
return redirect(url_for("main.planner", week=return_week))
|
||||||
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
|
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
|
||||||
|
|
||||||
visibility = normalize_visibility(request.form.get("visibility"), entry["visibility"])
|
visibility = normalize_visibility(request.form.get("visibility"), entry["visibility"])
|
||||||
note = request.form.get("note", "").strip()
|
note = request.form.get("note", "").strip()
|
||||||
update_plan_entry(entry_id, visibility=visibility, note=note)
|
update_plan_entry(entry_id, visibility=visibility, note=note)
|
||||||
flash("Der Planeintrag wurde angepasst.", "success")
|
flash("Der Planeintrag wurde angepasst.", "success")
|
||||||
|
if return_week:
|
||||||
|
return redirect(url_for("main.planner", week=return_week))
|
||||||
return redirect(url_for("main.planner_day", date=selected_date.isoformat(), daypart_id=entry["daypart_id"]))
|
return redirect(url_for("main.planner_day", date=selected_date.isoformat(), daypart_id=entry["daypart_id"]))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.post("/planner/<int:entry_id>/remove")
|
@main_bp.post("/planner/<int:entry_id>/remove")
|
||||||
@login_required
|
@login_required
|
||||||
def planner_remove(entry_id: int):
|
def planner_remove(entry_id: int):
|
||||||
selected_date = request.args.get("date", "")
|
selected_date = request.args.get("date", "") or request.form.get("plan_date", "").strip()
|
||||||
|
return_week = request.form.get("return_week", "").strip()
|
||||||
entry = get_db().execute(
|
entry = get_db().execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT plan_entries.*,
|
SELECT plan_entries.*,
|
||||||
@@ -3976,6 +4389,8 @@ def planner_remove(entry_id: int):
|
|||||||
flash("Der Planeintrag wurde entfernt.", "info")
|
flash("Der Planeintrag wurde entfernt.", "info")
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
|
if return_week:
|
||||||
|
return redirect(url_for("main.planner", week=return_week))
|
||||||
if selected_date:
|
if selected_date:
|
||||||
return redirect(url_for("main.planner_day", date=selected_date))
|
return redirect(url_for("main.planner_day", date=selected_date))
|
||||||
return redirect(url_for("main.planner"))
|
return redirect(url_for("main.planner"))
|
||||||
|
|||||||
@@ -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,13 @@ 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',
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -641,6 +641,18 @@ h3 {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .component-group,
|
||||||
|
[data-theme="dark"] .quick-food-panel {
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-soft) 74%, #4c413d 26%),
|
||||||
|
color-mix(in srgb, var(--surface) 94%, #2c2523 6%)
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.18) 42%);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
.quick-food-grid {
|
.quick-food-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -848,6 +860,195 @@ legend {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.daypart-option-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
min-height: 118px;
|
||||||
|
padding: 1rem 0.9rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
|
||||||
|
color: var(--muted);
|
||||||
|
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 78%, #fff 22%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option-icon .ui-icon {
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option-label {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option:hover .daypart-option-card {
|
||||||
|
border-color: var(--accent-soft);
|
||||||
|
color: var(--text);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option input:focus-visible + .daypart-option-card {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent 55%);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option input:checked + .daypart-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.daypart-option input:checked + .daypart-option-card .daypart-option-icon {
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, var(--surface) 84%);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 38%, var(--line) 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
min-height: 110px;
|
||||||
|
padding: 0.95rem 0.85rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
|
||||||
|
color: var(--muted);
|
||||||
|
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 78%, #fff 22%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option-icon .ui-icon {
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option-label {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option:hover .meal-style-option-card {
|
||||||
|
border-color: var(--accent-soft);
|
||||||
|
color: var(--text);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option input:focus-visible + .meal-style-option-card {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent 55%);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option input:checked + .meal-style-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-style-option input:checked + .meal-style-option-card .meal-style-option-icon {
|
||||||
|
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"] .daypart-option-card,
|
||||||
|
[data-theme="dark"] .meal-style-option-card {
|
||||||
|
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%);
|
||||||
|
color: color-mix(in srgb, var(--muted) 92%, white 8%);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 233, 217, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .daypart-option-icon,
|
||||||
|
[data-theme="dark"] .meal-style-option-icon {
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-soft) 82%, #544743 18%),
|
||||||
|
color-mix(in srgb, var(--surface) 88%, #2a2321 12%)
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--line) 52%, rgba(243, 177, 125, 0.22) 48%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .daypart-option:hover .daypart-option-card,
|
||||||
|
[data-theme="dark"] .meal-style-option:hover .meal-style-option-card {
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-soft) 66%, #554945 34%),
|
||||||
|
color-mix(in srgb, var(--surface-strong) 82%, #2b2523 18%)
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 28%, var(--line) 72%);
|
||||||
|
}
|
||||||
|
|
||||||
.quick-select-card {
|
.quick-select-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1583,6 +1784,17 @@ legend {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-chip.is-editable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip.is-editable:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 34%, var(--line) 66%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
||||||
|
0 10px 22px rgba(94, 68, 49, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.plan-chip:active {
|
.plan-chip:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
@@ -1602,6 +1814,55 @@ legend {
|
|||||||
padding: 0.55rem 0.85rem;
|
padding: 0.55rem 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.week-entry-dialog {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
max-width: min(34rem, calc(100vw - 2rem));
|
||||||
|
width: min(34rem, calc(100vw - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-entry-dialog::backdrop {
|
||||||
|
background: rgba(29, 22, 19, 0.54);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-entry-dialog-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.1rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--surface) 98%, #fff 2%);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-entry-dialog-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-entry-dialog-head h3 {
|
||||||
|
margin: 0 0 0.2rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-entry-dialog-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-entry-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-entry-remove-form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-chip small,
|
.plan-chip small,
|
||||||
.week-slot-empty {
|
.week-slot-empty {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -1687,6 +1948,13 @@ legend {
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .plan-chip.is-editable:hover {
|
||||||
|
border-color: rgba(243, 177, 125, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||||
|
0 12px 26px rgba(0, 0, 0, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .week-slot-copy {
|
[data-theme="dark"] .week-slot-copy {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border-color: rgba(243, 177, 125, 0.12);
|
border-color: rgba(243, 177, 125, 0.12);
|
||||||
@@ -1701,6 +1969,12 @@ legend {
|
|||||||
border-color: rgba(243, 177, 125, 0.16);
|
border-color: rgba(243, 177, 125, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .week-entry-dialog-card {
|
||||||
|
background: rgba(43, 37, 35, 0.98);
|
||||||
|
border-color: rgba(243, 177, 125, 0.14);
|
||||||
|
box-shadow: 0 24px 50px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
.flash-stack {
|
.flash-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
@@ -1808,6 +2082,46 @@ legend {
|
|||||||
mask-image: url("../icons/dayparts/late-snack.svg");
|
mask-image: url("../icons/dayparts/late-snack.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-meal-style-sweet {
|
||||||
|
-webkit-mask-image: url("../icons/meal-styles/sweet.svg");
|
||||||
|
mask-image: url("../icons/meal-styles/sweet.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-meal-style-savory {
|
||||||
|
-webkit-mask-image: url("../icons/meal-styles/savory.svg");
|
||||||
|
mask-image: url("../icons/meal-styles/savory.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-meal-style-warm {
|
||||||
|
-webkit-mask-image: url("../icons/meal-styles/warm.svg");
|
||||||
|
mask-image: url("../icons/meal-styles/warm.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-meal-style-cold {
|
||||||
|
-webkit-mask-image: url("../icons/meal-styles/cold.svg");
|
||||||
|
mask-image: url("../icons/meal-styles/cold.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-meal-style-quick {
|
||||||
|
-webkit-mask-image: url("../icons/meal-styles/quick.svg");
|
||||||
|
mask-image: url("../icons/meal-styles/quick.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-meal-style-simple {
|
||||||
|
-webkit-mask-image: url("../icons/meal-styles/simple.svg");
|
||||||
|
mask-image: url("../icons/meal-styles/simple.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-meal-style-prep {
|
||||||
|
-webkit-mask-image: url("../icons/meal-styles/prep.svg");
|
||||||
|
mask-image: url("../icons/meal-styles/prep.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-meal-style-portable {
|
||||||
|
-webkit-mask-image: url("../icons/meal-styles/portable.svg");
|
||||||
|
mask-image: url("../icons/meal-styles/portable.svg");
|
||||||
|
}
|
||||||
|
|
||||||
.icon-archive {
|
.icon-archive {
|
||||||
-webkit-mask-image: url("../icons/fa/archive.svg");
|
-webkit-mask-image: url("../icons/fa/archive.svg");
|
||||||
mask-image: url("../icons/fa/archive.svg");
|
mask-image: url("../icons/fa/archive.svg");
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M312.2 49.4L312.2 0L264.2 0L264.2 49.4L218.2 16.5L186.2 51.8L264.3 141L264.3 246.4L173 193.7L134.8 81.5L88.2 91.6L93.7 147.9L50.9 123.2L26.9 164.8L69.6 189.5L18.1 212.9L32.7 258.3L149 235.3L240.3 288L149 340.7L32.7 317.7L18.1 363.1L69.6 386.5L26.8 411.2L50.8 452.8L93.6 428.1L88.1 484.4L134.7 494.5L172.9 382.3L264.2 329.6L264.2 435L186.1 524.2L218.1 559.5L264.1 526.6L264.1 576L312.1 576L312.1 526.6L358.2 559.5L390.2 524.2L312.1 435L312.1 329.6L403.4 382.3L441.6 494.5L488.2 484.4L482.7 428.1L525.5 452.8L549.5 411.2L506.7 386.5L558.2 363.1L543.6 317.7L427.3 340.7L336 288L427.3 235.3L543.6 258.3L558.2 212.9L506.7 189.5L549.4 164.8L525.4 123.2L482.7 147.9L488.2 91.6L441.6 81.5L403.4 193.7L312.1 246.4L312.1 141L390.2 51.8L358.2 16.5L312.1 49.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1016 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M191.6 66.9C211.6 38.8 243.9 24 288 24C332.1 24 364.4 38.8 384.4 66.9C400.1 89 406 116.5 407.6 144L480 144C533 144 576 187 576 240L576 552L0 552L0 240C0 187 43 144 96 144L168.4 144C169.9 116.5 175.9 89 191.6 66.9zM324.4 92.1C320.7 85.1 316.3 80.4 311.2 77.4C306.1 74.4 298.8 72 288 72C277.2 72 269.9 74.3 264.8 77.4C259.7 80.5 255.3 85.1 251.6 92.1C245.1 104.2 241.5 121.7 240.4 144L335.6 144C334.5 121.7 330.9 104.2 324.4 92.1zM480 504L480 384L331.5 384C333.8 395.1 337.2 411.1 341.6 432L234.4 432C238.8 411.1 242.2 395.1 244.5 384L96 384L96 504L480 504zM321.4 336L480 336L480 240C480 213.5 458.5 192 432 192L144 192C117.5 192 96 213.5 96 240L96 336L254.6 336L264.5 289.1L311.5 289.1L321.4 336z"/></svg>
|
||||||
|
After Width: | Height: | Size: 963 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M118.2 24L457.8 24L464.7 35.9L548.7 179.9L552 185.5L552 456C552 509 509 552 456 552L120 552C67 552 24 509 24 456L24 185.5L27.3 179.9L111.3 35.9L118.2 24zM120 456C120 482.5 141.5 504 168 504L408 504C434.5 504 456 482.5 456 456L456 216L120 216L120 456zM129.3 168L264 168L264 72L161.3 72L129.3 168zM312 168L446.7 168L414.7 72L312 72L312 168z"/></svg>
|
||||||
|
After Width: | Height: | Size: 606 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M44.6 23C-9.1 63.4-6.2 115.1 25.7 157.4L179 41.9C147.1-.5 98.2-17.5 44.6 23zM399.9 41.9L553.2 157.4C585.1 115.1 588 63.4 534.3 23C480.6-17.4 431.8-.5 399.9 41.9zM288 120C339.2 120 373.7 140.5 396.4 173C419.9 206.7 432 255.2 432 312C432 368.8 419.9 417.3 396.4 451C373.7 483.5 339.1 504 288 504C236.9 504 202.3 483.5 179.6 451C156.1 417.3 144 368.8 144 312C144 255.2 156.1 206.7 179.6 173C202.3 140.5 236.9 120 288 120zM288 72C207 72 146 98.1 105.4 143.2C65.3 187.9 48 248.2 48 312C48 362 58.7 410 82.7 449.9L29.7 512.5L66.3 543.5L112.8 488.5C153.2 528.8 211.8 552 288 552C364.2 552 422.8 528.9 463.2 488.5L509.7 543.5L546.3 512.5L493.3 449.9C517.3 410 528 362 528 312C528 248.3 510.7 187.9 470.6 143.2C430 98.1 369 72 288 72zM312 192L264 192L264 329.3C265.7 329.9 295.1 339.7 352.4 358.8L364.4 362.8L379.6 317.3C378.9 317.1 356.3 309.5 312 294.8L312 192z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M528 24L488.2 5.9C481.9 11.4 476.2 17.5 470.5 23.6C460 34.9 445.4 51.8 429.1 74.4C396.4 119.6 356.1 187.8 325.3 280.4L314.8 312L471.8 312L399.8 552L528.1 552L528.1 24zM480 109.1L480 264L425.8 264C441.5 197 462 145.6 480 109.1zM144 192C117.5 192 96 170.5 96 144L96 24L48 24L48 192C48 241.5 85.5 282.3 133.6 287.4L78.5 552L257.5 552L202.4 287.4C250.5 282.2 288 241.5 288 192L288 24L240 24L240 144C240 170.5 218.5 192 192 192L192 24L144 24L144 192zM198.5 504L137.5 504L168 357.7L198.5 504z"/></svg>
|
||||||
|
After Width: | Height: | Size: 754 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M552.5 48L552.5 24L528.5 24L528.5 48L552.5 48zM129.3 481.1C175.6 520.8 224.7 538.1 273.2 535.5C327.3 532.6 376.7 505.1 417.1 464.8C486.5 395.4 519.7 289.4 535.9 204.4C544.1 161.3 548.2 122.4 550.3 94.3C551.3 80.2 551.8 68.8 552.1 60.8C552.2 56.8 552.3 53.7 552.3 51.6C552.3 50.5 552.3 49.7 552.3 49.1L552.3 48.4L552.3 48.2L552.3 48.1L552.3 48.1L528.3 48.1C528.3 24.1 528.3 24.1 528.3 24.1L528.2 24.1L528 24.1L527.3 24.1C526.7 24.1 525.9 24.1 524.8 24.1C522.6 24.1 519.5 24.2 515.6 24.3C507.6 24.6 496.2 25.1 482.1 26.1C454 28.2 415.1 32.3 372 40.5C288.6 56.4 185.2 88.6 115.8 155.2L115.8 155.2C115.1 155.9 114.4 156.6 113.8 157.3C113.1 158 112.4 158.7 111.7 159.4C71.3 199.8 43.9 249.2 40.9 303.3C38.3 351.8 55.6 401 95.3 447.2L7.5 535L41.4 569L129.3 481.1zM196.8 413.6L226.4 384L360.5 384L360.5 336L274.4 336L425.4 185L391.5 151.1L240.5 302.1L240.5 216L192.5 216L192.5 350.1L162.8 379.8C128.6 341.6 113 308.8 110.1 280.8C107 250.6 118.5 221.2 147.8 191.1C206.8 133.6 299.4 103.2 381.2 87.6C422 79.8 459 75.9 485.8 73.9C492.4 73.4 498.4 73.1 503.7 72.8C503.4 78.1 503.1 84 502.6 90.7C500.6 117.5 496.7 154.5 488.9 195.3C474.4 271 447.3 356.2 397.6 415.1L397.5 415C366.4 446.1 333.2 461.4 298.9 460.5C269 459.7 234.6 446.4 196.8 413.6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M312 144L312 48L264 48L264 144L192 144L192 48L144 48L144 144L96 144C43 144 0 187 0 240L0 528L576 528L576 240C576 187 533 144 480 144L432 144L432 48L384 48L384 144L312 144zM432 192C458.5 192 480 213.5 480 240L480 336C472.6 336 462.4 332.4 449 319C430.4 300.4 408.6 288 384 288C359.4 288 337.6 300.4 319 319C305.6 332.4 295.4 336 288 336C280.6 336 270.4 332.4 257 319C238.4 300.4 216.6 288 192 288C167.4 288 145.6 300.4 127 319C113.6 332.4 103.4 336 96 336L96 240C96 213.5 117.5 192 144 192L432 192zM96 480L96 384C120.6 384 142.4 371.6 161 353C174.4 339.6 184.6 336 192 336C199.4 336 209.6 339.6 223 353C241.6 371.6 263.4 384 288 384C312.6 384 334.4 371.6 353 353C366.4 339.6 376.6 336 384 336C391.4 336 401.6 339.6 415 353C433.6 371.6 455.4 384 480 384L480 480L96 480z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M309.6 0L266.4 0L222 47.9C208.1 62.9 188.5 71.2 168.1 70.9L89.2 63L63 89.4L70.9 164.2C71.2 183.7 63.6 202.5 49.8 216.3L0 266.1L0 309.6L47.9 354C62.9 367.9 71.2 387.5 70.9 407.9L63 486.7L91 512.7L164.3 499.9C188.2 497.7 211.6 507.6 226.7 526.3L266.9 575.9L309.2 575.9L349.4 526.3C364.5 507.6 387.9 497.8 411.8 499.9L485.1 512.7L513.1 486.7L505.2 407.9C504.9 387.5 513.2 367.9 528.2 354L576.1 309.6L576.1 266.1L526.3 216.3C512.5 202.5 504.9 183.7 505.2 164.2L513.1 89.4L486.8 63L408 70.9C387.6 71.2 368 62.9 354.1 47.9L309.6 0zM288 64.4L292.8 73.8C319.6 125.9 380.2 151 436 133.1L446 129.9L442.8 139.9C424.9 195.7 450 256.3 502.1 283.1L511.5 287.9L502.1 292.7C450 319.5 424.9 380.1 442.8 435.9L446 445.9L436 442.7C380.2 424.8 319.6 449.9 292.8 502L288 511.4L283.2 502C256.4 449.9 195.8 424.8 140 442.7L130 445.9L133.2 435.9C151.1 380.1 126 319.5 73.9 292.7L64.5 287.9L73.9 283.1C126 256.3 151.1 195.7 133.2 139.9L130 129.9L140 133.1C195.8 151 256.4 125.9 283.2 73.8L288 64.4zM322.3 224C330.9 238.4 336 260.5 336 288C336 315.5 330.9 337.6 322.3 352C314.5 365.1 303.9 372 288 372C272.1 372 261.5 365.1 253.7 352C245.1 337.6 240 315.5 240 288C240 260.5 245.1 238.4 253.7 224C261.5 210.9 272.1 204 288 204C303.9 204 314.5 210.9 322.3 224zM288 156C244.8 156 210.8 170 187.8 195.6C165.2 220.7 156 254.1 156 288C156 321.9 165.2 355.3 187.8 380.4C210.8 406 244.8 420 288 420C331.2 420 365.2 406 388.2 380.4C410.8 355.3 420 321.9 420 288C420 254.1 410.8 220.7 388.2 195.6C365.2 170 331.2 156 288 156z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -95,12 +95,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
rememberScroll();
|
||||||
if (result.redirect_url) {
|
if (result.redirect_url) {
|
||||||
window.location.href = result.redirect_url;
|
window.location.href = result.redirect_url;
|
||||||
} else {
|
} else {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
rememberScroll();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -196,6 +198,72 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initWeekEntryDialogs = () => {
|
||||||
|
const board = document.querySelector(".week-board");
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
const openDialog = (trigger) => {
|
||||||
|
const dialogId = trigger.getAttribute("data-week-entry-dialog-id");
|
||||||
|
if (!dialogId) return;
|
||||||
|
const dialog = document.getElementById(dialogId);
|
||||||
|
if (!(dialog instanceof HTMLDialogElement)) return;
|
||||||
|
if (!dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
board.querySelectorAll("[data-week-entry-open]").forEach((entry) => {
|
||||||
|
entry.addEventListener("click", (event) => {
|
||||||
|
if (event.target instanceof Element && event.target.closest("button, a, input, select, textarea, label, form")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openDialog(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
openDialog(entry);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".week-entry-dialog").forEach((dialog) => {
|
||||||
|
if (!(dialog instanceof HTMLDialogElement)) return;
|
||||||
|
|
||||||
|
dialog.addEventListener("click", (event) => {
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
const clickedInside =
|
||||||
|
rect.top <= event.clientY &&
|
||||||
|
event.clientY <= rect.top + rect.height &&
|
||||||
|
rect.left <= event.clientX &&
|
||||||
|
event.clientX <= rect.left + rect.width;
|
||||||
|
if (!clickedInside) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-week-entry-close]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const dialog = button.closest(".week-entry-dialog");
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".js-week-entry-submit").forEach((form) => {
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postAndRefreshInPlace(form);
|
||||||
|
} catch (_error) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const syncActionContainerVisibility = (container) => {
|
const syncActionContainerVisibility = (container) => {
|
||||||
if (!(container instanceof HTMLElement)) return;
|
if (!(container instanceof HTMLElement)) return;
|
||||||
const hasVisibleButtons = Array.from(container.querySelectorAll("button")).some((button) => {
|
const hasVisibleButtons = Array.from(container.querySelectorAll("button")).some((button) => {
|
||||||
@@ -285,6 +353,7 @@
|
|||||||
initWeekDragAndDrop();
|
initWeekDragAndDrop();
|
||||||
initWeekCopyForward();
|
initWeekCopyForward();
|
||||||
initWeekSlotPicker();
|
initWeekSlotPicker();
|
||||||
|
initWeekEntryDialogs();
|
||||||
initDaySnackReveal();
|
initDaySnackReveal();
|
||||||
initWeekSnackReveal();
|
initWeekSnackReveal();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,21 @@
|
|||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
{% if item.kind == 'food' %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
|
{% for tag in item.meal_tag_labels %}
|
||||||
|
<span class="chip">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
|
|||||||
@@ -115,7 +115,6 @@
|
|||||||
<a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
|
<a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
|
||||||
{% if g.user.role == 'admin' %}
|
{% if g.user.role == 'admin' %}
|
||||||
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
||||||
<a class="mobile-extra-link" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
|
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
|
||||||
<span class="ui-icon icon-sun-theme" data-theme-icon></span>
|
<span class="ui-icon icon-sun-theme" data-theme-icon></span>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Heute</p>
|
<p class="eyebrow">Heute</p>
|
||||||
<h1>Ein ruhiger Blick auf euren Alltag</h1>
|
<h1>Ein guter Blick auf euren Alltag</h1>
|
||||||
<p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo heute noch etwas ergänzt werden könnte.</p>
|
<p class="lead">Du siehst, was zuhause da ist, was schon geplant wurde, welche Vorlagen passen und wo heute noch etwas dazukommen kann.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
||||||
@@ -223,8 +223,8 @@
|
|||||||
<span>{{ card.planned_count }} Einträge</span>
|
<span>{{ card.planned_count }} Einträge</span>
|
||||||
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>Noch frei</span>
|
<span>Noch offen</span>
|
||||||
<small>ruhiger Einstieg für den Tag</small>
|
<small>Du kannst mit einem Eintrag anfangen.</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -107,7 +107,18 @@
|
|||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
{% if item.kind == 'food' %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.components %}
|
{% if item.components %}
|
||||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
<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 %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
||||||
<p class="lead">Nur das Nötigste: Name, Sichtbarkeit, für wen etwas gedacht ist, Bild, Tageszeiten und eine kleine Notiz.</p>
|
<p class="lead">
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
Name, Sichtbarkeit und ein paar ruhige Hinweise dazu, wie ein Lebensmittel in Vorschlägen gut passt.
|
||||||
|
{% else %}
|
||||||
|
Name, Sichtbarkeit, Tageszeit und ein kurzer Charakter der Idee. So bleiben Mahlzeitenideen alltagsnah und leicht pflegbar.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if item %}
|
{% if item %}
|
||||||
<div class="intro-pills">
|
<div class="intro-pills">
|
||||||
@@ -45,25 +51,107 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>
|
{% if kind == 'food' %}
|
||||||
Kategorie
|
<div class="dual-grid">
|
||||||
<select name="category">
|
<label>
|
||||||
<option value="">Ohne Kategorie</option>
|
Baustein
|
||||||
{% for category in categories %}
|
<select name="base_type">
|
||||||
<option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option>
|
{% for value, label in builder_options %}
|
||||||
{% endfor %}
|
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
</label>
|
</select>
|
||||||
|
<small class="helper-text">{{ builder_descriptions[form_data.base_type] }}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Energiedichte
|
Geschmacksrichtung
|
||||||
<select name="energy_density">
|
<select name="flavor_profile">
|
||||||
{% for value, label in energy_density_options %}
|
{% for value, label in food_flavor_options %}
|
||||||
<option value="{{ value }}" {% if form_data.energy_density == value %}selected{% endif %}>{{ label }}</option>
|
<option value="{{ value }}" {% if form_data.flavor_profile == value %}selected{% endif %}>{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
|
<small class="helper-text">{{ food_flavor_descriptions[form_data.flavor_profile] }}</small>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Rolle in Vorschlägen
|
||||||
|
<select name="suggestion_role">
|
||||||
|
{% for value, label in food_role_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.suggestion_role == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">{{ food_role_descriptions[form_data.suggestion_role] }}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Wird eher vorgeschlagen
|
||||||
|
<select name="suggestion_priority">
|
||||||
|
{% for value, label in suggestion_priority_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.suggestion_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="can_be_meal_core" value="1" {% if form_data.can_be_meal_core %}checked{% endif %}>
|
||||||
|
<span>Kann gut eine Mahlzeit tragen</span>
|
||||||
|
</label>
|
||||||
|
<small class="helper-text">Praktisch für Dinge wie Tofu, Joghurt oder Müsli. Extras wie Proteinpulver, Tomatenmark oder Saaten bleiben so eher Ergänzungen.</small>
|
||||||
|
{% else %}
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Mahlzeittyp
|
||||||
|
<select name="meal_type">
|
||||||
|
{% for value, label in meal_type_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.meal_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Energiedichte
|
||||||
|
<select name="energy_density">
|
||||||
|
{% for value, label in energy_density_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.energy_density == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Charakter der Mahlzeit</legend>
|
||||||
|
<div class="checkbox-grid meal-style-option-grid">
|
||||||
|
{% for value, label in meal_style_options %}
|
||||||
|
<label class="meal-style-option">
|
||||||
|
<input type="checkbox" name="meal_tags" value="{{ value }}" {% if value in form_data.meal_tags %}checked{% endif %}>
|
||||||
|
<span class="meal-style-option-card">
|
||||||
|
<span class="meal-style-option-icon">
|
||||||
|
<span class="ui-icon icon-meal-style-{{ value }}"></span>
|
||||||
|
</span>
|
||||||
|
<span class="meal-style-option-label">{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<small class="helper-text">Nur das auswählen, was wirklich hilft. Alles andere kann leer bleiben.</small>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
<label>
|
||||||
|
Energiedichte
|
||||||
|
<select name="energy_density">
|
||||||
|
{% for value, label in energy_density_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.energy_density == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Notiz
|
Notiz
|
||||||
@@ -88,11 +176,16 @@
|
|||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Passende Tageszeiten</legend>
|
<legend>Passende Tageszeiten</legend>
|
||||||
<div class="checkbox-grid">
|
<div class="checkbox-grid daypart-option-grid">
|
||||||
{% for daypart in dayparts %}
|
{% for daypart in dayparts %}
|
||||||
<label class="check-option">
|
<label class="daypart-option">
|
||||||
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||||
<span>{{ daypart.name }}</span>
|
<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>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +194,7 @@
|
|||||||
{% if kind == 'meal' %}
|
{% if kind == 'meal' %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||||
<p class="muted">Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.</p>
|
<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>
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Lebensmittel suchen
|
Lebensmittel suchen
|
||||||
@@ -128,9 +221,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
|
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
|
||||||
{% for food in group["items"] %}
|
{% for food in group["items"] %}
|
||||||
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }}">
|
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }} {{ food.base_type_label|lower }} {{ food.suggestion_role_label|lower }}">
|
||||||
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
||||||
<span>{{ food.name }} · {{ food.visibility_label }} · {{ food.for_label }}</span>
|
<span>{{ food.name }} · {{ food.base_type_label }} · {{ food.visibility_label }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -151,11 +244,34 @@
|
|||||||
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
|
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Baustein
|
||||||
<select name="quick_food_category">
|
<select name="quick_food_base_type">
|
||||||
<option value="">Ohne Kategorie</option>
|
{% for value, label in builder_options %}
|
||||||
{% for category in categories %}
|
<option value="{{ value }}" {% if form_data.quick_food_base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
<option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option>
|
{% 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">
|
||||||
|
{% for value, label in food_role_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.quick_food_role == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wird eher vorgeschlagen
|
||||||
|
<select name="quick_food_priority">
|
||||||
|
{% for value, label in suggestion_priority_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.quick_food_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -167,6 +283,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="quick_food_can_be_meal_core" value="1" {% if form_data.quick_food_can_be_meal_core %}checked{% endif %}>
|
||||||
|
<span>Kann gut eine Mahlzeit tragen</span>
|
||||||
|
</label>
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Notiz
|
Notiz
|
||||||
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
||||||
|
|||||||
@@ -74,10 +74,25 @@
|
|||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
{% if item.kind == 'food' %}
|
||||||
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
<div class="chip-row">
|
||||||
· {{ item_kind_labels[item.kind] }}
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
</p>
|
<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 %}
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
|
{% for tag in item.meal_tag_labels %}
|
||||||
|
<span class="chip">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Tagesplan</p>
|
<p class="eyebrow">Tagesplan</p>
|
||||||
<h1>{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}</h1>
|
<h1>{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}</h1>
|
||||||
<p class="lead">Der Tagesplan bleibt bewusst ruhig. Jede Tageszeit ist eine eigene Kachel und öffnet sich erst, wenn du sie brauchst.</p>
|
<p class="lead">Jede Tageszeit hat ihren eigenen Platz und öffnet sich erst dann, wenn du etwas eintragen möchtest.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=previous_day.isoformat()) }}">Vorheriger Tag</a>
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=previous_day.isoformat()) }}">Vorheriger Tag</a>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
{% if section.summary_items %}
|
{% if section.summary_items %}
|
||||||
<p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
|
<p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Noch frei. Öffnen, wenn du etwas ergänzen möchtest.</p>
|
<p class="muted">Noch offen. Öffnen, wenn du etwas eintragen möchtest.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Wochenansicht</p>
|
<p class="eyebrow">Wochenansicht</p>
|
||||||
<h1>Ein ruhiger Blick auf die nächsten sieben Tage</h1>
|
<h1>Die nächsten sieben Tage auf einen Blick</h1>
|
||||||
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und gleichzeitig sehen, was erst später für den Einkauf relevant wird.</p>
|
<p class="lead">Du kannst Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und sehen, was erst später für den Einkauf wichtig wird.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not card.filled_dayparts %}
|
{% if not card.filled_dayparts %}
|
||||||
<p class="empty-state week-card-empty-copy">Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.</p>
|
<p class="empty-state week-card-empty-copy">Noch offen. Du kannst den Tag nach und nach füllen.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if card.hidden_snack_slots %}
|
{% if card.hidden_snack_slots %}
|
||||||
@@ -213,10 +213,61 @@
|
|||||||
{% if slot.entries %}
|
{% if slot.entries %}
|
||||||
<div class="week-entry-stack">
|
<div class="week-entry-stack">
|
||||||
{% for entry in slot.entries %}
|
{% for entry in slot.entries %}
|
||||||
<article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
|
<article
|
||||||
|
class="plan-chip draggable-plan-entry{% if entry.can_edit %} is-editable{% endif %}"
|
||||||
|
draggable="{{ 'true' if entry.can_edit else 'false' }}"
|
||||||
|
data-entry-id="{{ entry.id }}"
|
||||||
|
data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}"
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
data-week-entry-open
|
||||||
|
data-week-entry-dialog-id="week-entry-dialog-{{ entry.id }}"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="{{ entry.item_name }} bearbeiten"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
|
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
|
||||||
</article>
|
</article>
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
<dialog class="week-entry-dialog" id="week-entry-dialog-{{ entry.id }}">
|
||||||
|
<div class="week-entry-dialog-card">
|
||||||
|
<div class="week-entry-dialog-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ entry.item_name }}</h3>
|
||||||
|
<p>{{ slot.daypart.name }} · {{ weekday_name(card.date) }}, {{ card.date.strftime('%d.%m.%Y') }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" type="button" data-week-entry-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form js-week-entry-submit">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="return_week" value="{{ week_start.isoformat() }}">
|
||||||
|
<label>
|
||||||
|
Für wen?
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if entry.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Notiz
|
||||||
|
<input type="text" name="note" value="{{ entry.note or '' }}" placeholder="Optional">
|
||||||
|
</label>
|
||||||
|
<div class="week-entry-dialog-actions">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id) }}" class="week-entry-remove-form js-week-entry-submit">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="return_week" value="{{ week_start.isoformat() }}">
|
||||||
|
<button class="ghost-button" type="submit">Eintrag entfernen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="week-slot-actions">
|
<div class="week-slot-actions">
|
||||||
|
|||||||
@@ -109,6 +109,15 @@
|
|||||||
Ausgewogen bleibt offen. Fitness denkt stärker in proteinbetont und eher leicht. Proteinbetont priorisiert Eiweiß, ohne extra leicht zu werden.
|
Ausgewogen bleibt offen. Fitness denkt stärker in proteinbetont und eher leicht. Proteinbetont priorisiert Eiweiß, ohne extra leicht zu werden.
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Proteinquellen eher
|
||||||
|
<select name="protein_preference">
|
||||||
|
{% for value, label in protein_preference_options %}
|
||||||
|
<option value="{{ value }}" {% if user_settings.protein_preference == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">Hilft dabei, Fleisch und Fisch bei Bedarf leiser mitzudenken, ohne sie ganz auszuschließen.</small>
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|||||||