From 1490fc8f1d8277aa194f834962b7809b1414efce Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Mon, 13 Apr 2026 12:00:40 +0200 Subject: [PATCH] Refine meal suggestions and planner entry controls --- nouri/db.py | 21 +++- nouri/main.py | 169 +++++++++++++++++++++++++++++-- nouri/reminders.py | 17 ++-- nouri/schema.sql | 9 ++ nouri/static/css/styles.css | 49 ++++++++- nouri/templates/dashboard.html | 20 +++- nouri/templates/home/list.html | 20 +++- nouri/templates/planner/day.html | 64 +++++++++--- 8 files changed, 334 insertions(+), 35 deletions(-) diff --git a/nouri/db.py b/nouri/db.py index 5bf0cf4..569ec57 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -10,7 +10,7 @@ from werkzeug.security import generate_password_hash from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS -CURRENT_SCHEMA_VERSION = "1.0.0" +CURRENT_SCHEMA_VERSION = "1.0.1" def get_db() -> sqlite3.Connection: @@ -205,6 +205,19 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: """ ) + database.execute( + """ + CREATE TABLE IF NOT EXISTS hidden_generated_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + suggestion_key TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, suggestion_key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + database.execute( """ CREATE TABLE IF NOT EXISTS shopping_needs ( @@ -456,6 +469,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON shopping_needs (household_id, activation_date, is_activated) """ ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_hidden_generated_suggestions_user + ON hidden_generated_suggestions (user_id) + """ + ) set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION) diff --git a/nouri/main.py b/nouri/main.py index f00c638..6954236 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -357,7 +357,12 @@ def describe_record(entry: dict) -> dict: entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam") entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "") entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}" - entry["for_label"] = f"Für {target_name}" if target_name else "Für alle" + if target_name: + entry["for_label"] = f"Für {target_name}" + elif entry["is_personal"]: + entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}" + else: + entry["for_label"] = "Für alle" entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin" return entry @@ -1267,6 +1272,23 @@ def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]: return tuple(sorted({int(component_id) for component_id in component_ids})) +def generated_suggestion_key(component_ids: list[int]) -> str: + signature = normalized_component_signature(component_ids) + return "generated:" + "-".join(str(component_id) for component_id in signature) + + +def fetch_hidden_generated_suggestion_keys() -> set[str]: + rows = get_db().execute( + """ + SELECT suggestion_key + FROM hidden_generated_suggestions + WHERE user_id = ? + """, + (g.user["id"],), + ).fetchall() + return {row["suggestion_key"] for row in rows} + + def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str: names = [item["name"] for item in combo] if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2: @@ -1341,6 +1363,13 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li "reason": pattern["reason"], "component_ids": [item["id"] for item in combo_items], "existing_item_id": None, + "visibility": "shared", + "daypart_id": None, + "missing_component_ids": [], + "missing_components": [], + "needs_shopping": False, + "is_generated": True, + "suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]), } ) if len(suggestions) >= limit * 3: @@ -1353,6 +1382,7 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: settings = get_user_settings() daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "") + hidden_keys = fetch_hidden_generated_suggestion_keys() home_foods = [ item for item in fetch_items(kind="food", availability="home") @@ -1360,30 +1390,76 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) ] home_food_ids = {item["id"] for item in home_foods} home_food_map = {int(item["id"]): item for item in home_foods} + visible_foods = [ + item + for item in fetch_items(kind="food", include_archived=False) + if item_matches_daypart(item, daypart_id) + ] + visible_food_map = {int(item["id"]): item for item in visible_foods} suggestions: list[dict] = [] seen_signatures: set[tuple[int, ...]] = set() meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)] for meal in meals: - if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]): - signature = normalized_component_signature(meal["component_ids"]) - if signature in seen_signatures: - continue - seen_signatures.add(signature) - component_items = [home_food_map[component_id] for component_id in meal["component_ids"] if component_id in home_food_map] + if not meal["component_ids"]: + continue + component_ids = [int(component_id) for component_id in meal["component_ids"]] + if not all(component_id in visible_food_map for component_id in component_ids): + continue + + signature = normalized_component_signature(component_ids) + if signature in seen_signatures: + continue + + component_items = [visible_food_map[component_id] for component_id in component_ids] + 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] + + if not available_items: + continue + if missing_items and len(missing_items) > 2: + continue + + seen_signatures.add(signature) + if missing_items: + missing_names = [item["name"] for item in missing_items] + suggestions.append( + { + "title": meal["name"], + "reason": f"Es fehlt noch: {', '.join(missing_names)}", + "component_ids": component_ids, + "existing_item_id": meal["id"], + "visibility": meal["visibility"], + "daypart_id": daypart_id or meal.get("primary_daypart_id"), + "missing_component_ids": [item["id"] for item in missing_items], + "missing_components": missing_names, + "needs_shopping": True, + "is_generated": False, + "suggestion_key": None, + "score": score_suggestion_components(available_items, daypart_slug=daypart_slug, settings=settings) + 18 - (len(missing_items) * 4), + } + ) + else: suggestions.append( { "title": meal["name"], "reason": "Zuhause vorhanden", - "component_ids": meal["component_ids"], + "component_ids": component_ids, "existing_item_id": meal["id"], + "visibility": meal["visibility"], + "daypart_id": daypart_id or meal.get("primary_daypart_id"), + "missing_component_ids": [], + "missing_components": [], + "needs_shopping": False, + "is_generated": False, + "suggestion_key": None, "score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40, } ) for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2): signature = normalized_component_signature(suggestion["component_ids"]) - if signature in seen_signatures: + if signature in seen_signatures or suggestion["suggestion_key"] in hidden_keys: continue seen_signatures.add(signature) component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map] @@ -1460,6 +1536,13 @@ def build_daypart_suggestions(daypart_id: int) -> list[dict]: "reason": "Für später vormerken", "component_ids": [], "existing_item_id": item["id"] if item["kind"] == "meal" else None, + "visibility": item["visibility"], + "daypart_id": daypart_id, + "missing_component_ids": [], + "missing_components": [], + "needs_shopping": False, + "is_generated": False, + "suggestion_key": None, } for item in archived_items[:2] ] @@ -2397,6 +2480,23 @@ def insert_plan_entry(*, item_id: int, daypart_id: int, plan_date: date, visibil ) +def update_plan_entry(entry_id: int, *, visibility: str, note: str) -> None: + get_db().execute( + """ + UPDATE plan_entries + SET visibility = ?, + owner_user_id = CASE + WHEN ? = 'personal' THEN ? + ELSE owner_user_id + END, + note = ? + WHERE id = ? + """, + (visibility, visibility, g.user["id"], note, entry_id), + ) + get_db().commit() + + def planner_template_options(): return fetch_day_templates() @@ -2457,6 +2557,25 @@ def template_library(): ) +@main_bp.post("/suggestions/hide") +@login_required +def suggestion_hide(): + component_ids = [int(value) for value in request.form.getlist("component_ids") if value.isdigit()] + if not component_ids: + flash("Diese Kombination konnte gerade nicht ausgeblendet werden.", "error") + return redirect(request.referrer or url_for("main.dashboard")) + get_db().execute( + """ + INSERT OR IGNORE INTO hidden_generated_suggestions (user_id, suggestion_key) + VALUES (?, ?) + """, + (g.user["id"], generated_suggestion_key(component_ids)), + ) + get_db().commit() + flash("Diese generierte Mahlzeit wird dir künftig nicht mehr vorgeschlagen.", "info") + return redirect(request.referrer or url_for("main.dashboard")) + + @main_bp.route("/templates/day/new", methods=("GET", "POST")) @login_required def day_template_create(): @@ -3580,6 +3699,38 @@ def planner_generated_meal(): return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}") +@main_bp.post("/planner//update") +@login_required +def planner_update(entry_id: int): + selected_date = parse_plan_date(request.form.get("plan_date")) + entry = get_db().execute( + f""" + SELECT plan_entries.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username + FROM plan_entries + LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id + WHERE plan_entries.id = ? AND {visible_clause('plan_entries')} + """, + [entry_id, *visible_params()], + ).fetchone() + if entry is None: + flash("Der Planeintrag wurde nicht gefunden.", "error") + return redirect(url_for("main.planner_day", date=selected_date.isoformat())) + + try: + ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht bearbeiten.") + except PermissionError as exc: + flash(str(exc), "error") + return redirect(url_for("main.planner_day", date=selected_date.isoformat())) + + visibility = normalize_visibility(request.form.get("visibility"), entry["visibility"]) + note = request.form.get("note", "").strip() + update_plan_entry(entry_id, visibility=visibility, note=note) + flash("Der Planeintrag wurde angepasst.", "success") + return redirect(url_for("main.planner_day", date=selected_date.isoformat(), daypart_id=entry["daypart_id"])) + + @main_bp.post("/planner//remove") @login_required def planner_remove(entry_id: int): diff --git a/nouri/reminders.py b/nouri/reminders.py index 92cc4a0..d36d93c 100644 --- a/nouri/reminders.py +++ b/nouri/reminders.py @@ -13,9 +13,9 @@ from .push import send_push_message MEAL_PUSH_RULES = [ - {"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "label": "Frühstück"}, - {"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "label": "Mittagessen"}, - {"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "label": "Abendessen"}, + {"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "end_hour": 12, "label": "Frühstück"}, + {"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "end_hour": 18, "label": "Mittagessen"}, + {"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "end_hour": 24, "label": "Abendessen"}, ] @@ -92,10 +92,11 @@ def mark_reminder_event(user_id: int, event_key: str) -> None: get_db().commit() -def due_for_rule(now: datetime, *, hour: int, minute: int) -> bool: - target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) - delta = (now - target).total_seconds() - return 0 <= delta < 180 +def due_for_rule(now: datetime, *, hour: int, minute: int, end_hour: int) -> bool: + current_minutes = (now.hour * 60) + now.minute + target_minutes = (hour * 60) + minute + end_minutes = end_hour * 60 + return target_minutes <= current_minutes < end_minutes def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str: @@ -155,7 +156,7 @@ def send_due_meal_pushes(now: datetime | None = None) -> int: for rule in MEAL_PUSH_RULES: if not settings.get(rule["setting"]): continue - if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"]): + if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"], end_hour=rule["end_hour"]): continue daypart = dayparts.get(rule["slug"]) diff --git a/nouri/schema.sql b/nouri/schema.sql index b3202d8..ac275b4 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -94,6 +94,15 @@ CREATE TABLE IF NOT EXISTS reminder_events ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS hidden_generated_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + suggestion_key TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, suggestion_key), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS dayparts ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index d6473f9..631ff3a 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -89,7 +89,8 @@ textarea { } button, -.button { +.button, +.ghost-button { display: inline-flex; align-items: center; justify-content: center; @@ -937,6 +938,31 @@ legend { background: color-mix(in srgb, var(--surface) 88%, #fff 12%); } +.planner-entry-edit { + margin-top: 0.85rem; +} + +.planner-entry-edit > summary { + width: fit-content; + cursor: pointer; + list-style: none; +} + +.planner-entry-edit > summary::-webkit-details-marker { + display: none; +} + +.planner-entry-inline-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.8rem; + margin-top: 0.8rem; +} + +.planner-entry-inline-form .wide { + grid-column: 1 / -1; +} + .template-card, .template-list-card, .suggestion-card { @@ -952,6 +978,23 @@ legend { gap: 0.9rem; } +.template-list-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; + align-items: center; +} + +.template-list-card-actions form { + margin: 0; +} + +.template-list-card .ghost-button, +.template-list-card .button { + width: auto; + align-self: flex-start; +} + .week-template-row { padding: 1rem; border-radius: 18px; @@ -1505,6 +1548,10 @@ legend { min-width: 100%; } + .planner-entry-inline-form { + grid-template-columns: 1fr; + } + .mobile-nav-stack { position: fixed; left: 0.75rem; diff --git a/nouri/templates/dashboard.html b/nouri/templates/dashboard.html index cf1c24a..591778f 100644 --- a/nouri/templates/dashboard.html +++ b/nouri/templates/dashboard.html @@ -134,8 +134,26 @@
{{ suggestion.title }} {{ suggestion.reason }} + {% if suggestion.needs_shopping and suggestion.missing_components %} +
+ Es fehlt noch: {{ suggestion.missing_components|join(', ') }} +
+ {% endif %} +
+
+ {% if suggestion.existing_item_id %} + Im Tagesplan öffnen + {% else %} + Als Mahlzeit anlegen +
+ {{ csrf_input() }} + {% for component_id in suggestion.component_ids %} + + {% endfor %} + +
+ {% endif %}
- Als Mahlzeit anlegen {% endfor %} diff --git a/nouri/templates/home/list.html b/nouri/templates/home/list.html index 742a083..ebc0f10 100644 --- a/nouri/templates/home/list.html +++ b/nouri/templates/home/list.html @@ -51,8 +51,26 @@
{{ suggestion.title }} {{ suggestion.reason }} + {% if suggestion.needs_shopping and suggestion.missing_components %} +
+ Es fehlt noch: {{ suggestion.missing_components|join(', ') }} +
+ {% endif %} +
+
+ {% if suggestion.existing_item_id %} + Im Tagesplan öffnen + {% else %} + Als Mahlzeit anlegen +
+ {{ csrf_input() }} + {% for component_id in suggestion.component_ids %} + + {% endfor %} + +
+ {% endif %}
- Als Mahlzeit anlegen {% endfor %} diff --git a/nouri/templates/planner/day.html b/nouri/templates/planner/day.html index 5c83bfd..68881c5 100644 --- a/nouri/templates/planner/day.html +++ b/nouri/templates/planner/day.html @@ -150,20 +150,34 @@

Passt gut dazu

{% for suggestion in section.recipe_suggestions %} -
- {{ csrf_input() }} - - - - - {% for component_id in suggestion.component_ids %} - - {% endfor %} - -
+ {% if suggestion.existing_item_id %} +
+ {{ csrf_input() }} + + + + + +
+ {% else %} +
+ {{ csrf_input() }} + + + + + {% for component_id in suggestion.component_ids %} + + {% endfor %} + +
+ {% endif %} {% endfor %}
@@ -220,6 +234,28 @@ {% if entry.note %}

{{ entry.note }}

{% endif %} + {% if entry.can_edit %} +
+ Anpassen +
+ {{ csrf_input() }} + + + + +
+
+ {% endif %} {% endfor %}