diff --git a/CloudronManifest.json b/CloudronManifest.json index 3bdfda2..eef58bd 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "0.3.0", - "upstreamVersion": "0.3.0", + "version": "0.4.0", + "upstreamVersion": "0.4.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/README.md b/README.md index 138140f..eb3828b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. -## Merkmale in Version 0.3 +## Merkmale in Version 0.4 - Lebensmittel und Mahlzeitenideen anlegen - Fotos lokal hochladen @@ -17,6 +17,12 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein - Profilseite und Passwortänderung - kleine Admin-Verwaltung für Nutzer - kompaktere mobile Navigation mit Bottom-Bar +- Tagesvorlagen und Wochenvorlagen +- kleine Pakete für wiederkehrende Einkaufs- oder Planungsbausteine +- sanfte Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen +- globale Kategorien pro Haushalt +- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten +- Mobile-Mehr-Menü als Sheet statt eigener Seite ## Lokal starten @@ -39,9 +45,9 @@ Wichtige Umgebungsvariablen: - `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` -## Migration von 0.2 auf 0.3 +## Migration von 0.3 auf 0.4 -Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite: Haushalt, Rollen, Aktiv-Status und Sichtbarkeit (`persönlich` oder `Für alle`) werden ergänzt. Vorhandene 0.2-Daten bleiben erhalten und werden automatisch einem gemeinsamen Haushaltskontext zugeordnet. +Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Vorlagen, kleine Pakete, Kategorien pro Haushalt, Zielnutzer an Lebensmitteln und zusätzliche Einkaufs-Kontexte werden ergänzt. Vorhandene 0.3-Daten bleiben erhalten und werden weiterverwendet. ## Cloudron-Hinweis diff --git a/nouri/__init__.py b/nouri/__init__.py index 1529afb..0bf8ba1 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -11,8 +11,8 @@ from . import db from .admin import admin_bp from .auth import auth_bp from .constants import ( - CATEGORIES, DAYPARTS, + DEFAULT_CATEGORIES, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, ROLE_LABELS, @@ -77,7 +77,7 @@ def create_app() -> Flask: return { "item_kind_labels": ITEM_KIND_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, - "category_suggestions": CATEGORIES, + "category_suggestions": DEFAULT_CATEGORIES, "daypart_suggestions": DAYPARTS, "visibility_labels": VISIBILITY_LABELS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS, diff --git a/nouri/admin.py b/nouri/admin.py index 7ba2278..7f519eb 100644 --- a/nouri/admin.py +++ b/nouri/admin.py @@ -4,7 +4,7 @@ from flask import Blueprint, flash, g, redirect, render_template, request, url_f from werkzeug.security import generate_password_hash from .auth import admin_required, can_remove_last_admin, validate_admin_user_form -from .constants import ROLE_LABELS +from .constants import DEFAULT_CATEGORIES, ROLE_LABELS from .db import get_db @@ -26,6 +26,18 @@ def get_household_user(user_id: int): return user +def fetch_household_categories(): + return get_db().execute( + """ + SELECT * + FROM household_categories + WHERE household_id = ? + ORDER BY is_active DESC, sort_order, LOWER(name) + """, + (g.user["household_id"],), + ).fetchall() + + @admin_bp.get("/users") @admin_required def user_list(): @@ -178,3 +190,73 @@ def user_edit(user_id: int): flash(error, "error") return render_template("admin/user_form.html", user=user, form_data=form_data, role_labels=ROLE_LABELS) + + +@admin_bp.route("/categories", methods=("GET", "POST")) +@admin_required +def category_settings(): + if request.method == "POST": + name = request.form.get("name", "").strip() + if not name: + flash("Bitte einen Kategorienamen eintragen.", "error") + else: + existing = get_db().execute( + """ + SELECT id + FROM household_categories + WHERE household_id = ? AND LOWER(name) = LOWER(?) + """, + (g.user["household_id"], name), + ).fetchone() + if existing: + get_db().execute( + "UPDATE household_categories SET is_active = 1 WHERE id = ?", + (existing["id"],), + ) + flash("Die Kategorie ist wieder aktiv.", "success") + else: + sort_row = get_db().execute( + "SELECT COALESCE(MAX(sort_order), 0) AS max_sort FROM household_categories WHERE household_id = ?", + (g.user["household_id"],), + ).fetchone() + get_db().execute( + """ + INSERT INTO household_categories (household_id, name, sort_order, is_active) + VALUES (?, ?, ?, 1) + """, + (g.user["household_id"], name, int(sort_row["max_sort"]) + 10), + ) + flash("Die Kategorie wurde ergänzt.", "success") + get_db().commit() + return redirect(url_for("admin.category_settings")) + + return render_template( + "admin/categories.html", + categories=fetch_household_categories(), + default_categories=DEFAULT_CATEGORIES, + ) + + +@admin_bp.post("/categories//toggle") +@admin_required +def category_toggle(category_id: int): + category = get_db().execute( + """ + SELECT * + FROM household_categories + WHERE id = ? AND household_id = ? + """, + (category_id, g.user["household_id"]), + ).fetchone() + if category is None: + flash("Die Kategorie wurde nicht gefunden.", "error") + return redirect(url_for("admin.category_settings")) + + new_state = 0 if category["is_active"] else 1 + get_db().execute( + "UPDATE household_categories SET is_active = ? WHERE id = ?", + (new_state, category_id), + ) + get_db().commit() + flash("Die Kategorie wurde aktualisiert.", "success") + return redirect(url_for("admin.category_settings")) diff --git a/nouri/constants.py b/nouri/constants.py index f44c8bc..e038155 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -7,7 +7,7 @@ DAYPARTS = [ {"slug": "late-snack", "name": "Später Snack", "sort_order": 60}, ] -CATEGORIES = [ +DEFAULT_CATEGORIES = [ "Brot & Getreide", "Milchprodukt", "Obst", @@ -42,7 +42,7 @@ ROLE_LABELS = { } VISIBILITY_LABELS = { - "shared": "Für alle", + "shared": "Gemeinsam", "personal": "Persönlich", } @@ -50,3 +50,26 @@ VISIBILITY_DESCRIPTIONS = { "shared": "Gemeinsam im Haushalt sichtbar und nutzbar.", "personal": "Nur für dich sichtbar und planbar.", } + +DAY_TEMPLATE_NAME_SUGGESTIONS = [ + "Ruhiger Tag", + "Einfacher Bürotag", + "Schwieriger Tag", + "Standard-Frühstückstag", + "Tag mit wenig Energie", +] + +WEEK_TEMPLATE_NAME_SUGGESTIONS = [ + "Standardwoche", + "Büro-Woche", + "Leichte Woche", + "Woche mit wenig Energie", + "Frühstücks-Woche", +] + +ITEM_SET_NAME_SUGGESTIONS = [ + "Schnelles Frühstück", + "Sicherer Snack", + "Einfaches Abendessen", + "Einkauf für zwei Tage", +] diff --git a/nouri/db.py b/nouri/db.py index 148309b..f198733 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -8,7 +8,7 @@ from flask import Flask, current_app, g from flask.cli import with_appcontext from werkzeug.security import generate_password_hash -from .constants import DAYPARTS +from .constants import DAYPARTS, DEFAULT_CATEGORIES def get_db() -> sqlite3.Connection: @@ -33,6 +33,14 @@ def table_columns(database: sqlite3.Connection, table_name: str) -> set[str]: return {row["name"] for row in rows} +def table_exists(database: sqlite3.Connection, table_name: str) -> bool: + row = database.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + (table_name,), + ).fetchone() + return row is not None + + def add_column_if_missing(database: sqlite3.Connection, table_name: str, definition: str) -> None: column_name = definition.split()[0] if column_name not in table_columns(database, table_name): @@ -50,31 +58,41 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: """ ) - existing_tables = { - row["name"] - for row in database.execute( - "SELECT name FROM sqlite_master WHERE type = 'table'" - ).fetchall() - } + database.execute( + """ + CREATE TABLE IF NOT EXISTS household_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (household_id, name) + ) + """ + ) - if "users" in existing_tables: + if table_exists(database, "users"): add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "email TEXT") add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'") add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1") add_column_if_missing(database, "users", "updated_at TEXT") - if "items" in existing_tables: + if table_exists(database, "items"): add_column_if_missing(database, "items", "household_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", "visibility TEXT NOT NULL DEFAULT 'shared'") - if "shopping_entries" in existing_tables: + if table_exists(database, "shopping_entries"): add_column_if_missing(database, "shopping_entries", "household_id INTEGER") add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER") add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'") + add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") + add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") - if "plan_entries" in existing_tables: + if table_exists(database, "plan_entries"): add_column_if_missing(database, "plan_entries", "household_id INTEGER") add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER") add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'") @@ -91,9 +109,11 @@ def ensure_default_household(database: sqlite3.Connection) -> int: "INSERT INTO households (name) VALUES (?)", ("Unser Haushalt",), ) - return int( - database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"] - ) + return int(database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"]) + + +def household_ids(database: sqlite3.Connection) -> list[int]: + return [int(row["id"]) for row in database.execute("SELECT id FROM households ORDER BY id").fetchall()] def first_user_id(database: sqlite3.Connection) -> int | None: @@ -101,6 +121,18 @@ def first_user_id(database: sqlite3.Connection) -> int | None: return int(row["id"]) if row else None +def sync_default_categories(database: sqlite3.Connection) -> None: + for household_id in household_ids(database): + for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10): + database.execute( + """ + INSERT OR IGNORE INTO household_categories (household_id, name, sort_order, is_active) + VALUES (?, ?, ?, 1) + """, + (household_id, name, sort_order), + ) + + def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "email TEXT") @@ -113,18 +145,10 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: "UPDATE users SET household_id = ? WHERE household_id IS NULL", (default_household_id,), ) - database.execute( - "UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''", - ) - database.execute( - "UPDATE users SET is_active = 1 WHERE is_active IS NULL", - ) - database.execute( - "UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''", - ) - database.execute( - "UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)" - ) + database.execute("UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''") + database.execute("UPDATE users SET is_active = 1 WHERE is_active IS NULL") + database.execute("UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''") + database.execute("UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)") admin_row = database.execute( "SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY id LIMIT 1" @@ -132,16 +156,16 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: if admin_row is None: first_id = first_user_id(database) if first_id is not None: - database.execute( - "UPDATE users SET role = 'admin' WHERE id = ?", - (first_id,), - ) + database.execute("UPDATE users SET role = 'admin' WHERE id = ?", (first_id,)) default_owner_id = first_user_id(database) for table_name in ("items", "shopping_entries", "plan_entries"): add_column_if_missing(database, table_name, "household_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, "items", "target_user_id INTEGER") + add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") + add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") if default_owner_id is not None: database.execute( @@ -175,15 +199,11 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: (default_household_id, default_owner_id), ) else: - database.execute( - "UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''" - ) - database.execute( - "UPDATE shopping_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''" - ) - database.execute( - "UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''" - ) + database.execute("UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''") + database.execute("UPDATE shopping_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) database.execute( """ @@ -198,6 +218,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON items (household_id, visibility, availability_state) """ ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_items_target_user + ON items (target_user_id) + """ + ) database.execute( """ CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility diff --git a/nouri/main.py b/nouri/main.py index 986dbb4..50d3262 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -21,11 +21,14 @@ from werkzeug.utils import secure_filename from .auth import login_required from .constants import ( AVAILABILITY_LABELS, - CATEGORIES, + DAY_TEMPLATE_NAME_SUGGESTIONS, + DEFAULT_CATEGORIES, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, + ITEM_SET_NAME_SUGGESTIONS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, + WEEK_TEMPLATE_NAME_SUGGESTIONS, ) from .db import get_db @@ -45,19 +48,79 @@ KIND_FILTER_OPTIONS = [ ] VISIBILITY_FILTER_OPTIONS = [ ("", "Alles Sichtbare"), - ("shared", "Für alle"), + ("shared", "Gemeinsam"), ("personal", "Persönlich"), ] VISIBILITY_FORM_OPTIONS = [ - ("shared", "Für alle"), + ("shared", "Gemeinsam"), ("personal", "Persönlich"), ] +TARGET_USER_OPTIONS_DEFAULT = "__all__" +WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] + + +def current_household_id() -> int: + return int(g.user["household_id"]) def get_dayparts() -> list: return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall() +def get_household_users(active_only: bool = True): + query = """ + SELECT id, username, display_name, role + FROM users + WHERE household_id = ? + """ + params: list[object] = [current_household_id()] + if active_only: + query += " AND is_active = 1" + query += " ORDER BY LOWER(COALESCE(display_name, username))" + return get_db().execute(query, params).fetchall() + + +def get_target_user_options() -> list[dict]: + options = [{"value": TARGET_USER_OPTIONS_DEFAULT, "label": "Für alle"}] + for user in get_household_users(): + options.append( + { + "value": str(user["id"]), + "label": user["display_name"] or user["username"], + } + ) + return options + + +def get_category_options(include_inactive_selected: str | None = None) -> list[str]: + rows = get_db().execute( + """ + SELECT name + FROM household_categories + WHERE household_id = ? AND is_active = 1 + ORDER BY sort_order, LOWER(name) + """, + (current_household_id(),), + ).fetchall() + categories = [row["name"] for row in rows] + if not categories: + categories = DEFAULT_CATEGORIES[:] + if include_inactive_selected and include_inactive_selected not in categories: + categories.append(include_inactive_selected) + return categories + + +def visible_clause(table_alias: str) -> str: + return ( + f"{table_alias}.household_id = ? " + f"AND ({table_alias}.visibility = 'shared' OR {table_alias}.owner_user_id = ?)" + ) + + +def visible_params() -> list[int]: + return [current_household_id(), int(g.user["id"])] + + def parse_week_start(raw: str | None) -> date: if raw: try: @@ -79,51 +142,17 @@ def parse_plan_date(raw: str | None, fallback: date | None = None) -> date: def normalize_visibility(raw: str | None, default: str = "shared") -> str: - if raw in VISIBILITY_LABELS: - return raw - return default + return raw if raw in VISIBILITY_LABELS else default -def current_household_id() -> int: - return int(g.user["household_id"]) - - -def visible_clause(table_alias: str) -> str: - return ( - f"{table_alias}.household_id = ? " - f"AND ({table_alias}.visibility = 'shared' OR {table_alias}.owner_user_id = ?)" - ) - - -def visible_params() -> list[int]: - return [current_household_id(), int(g.user["id"])] - - -def user_display_name(display_name: str | None, username: str | None) -> str: - return display_name or username or "Haushalt" - - -def describe_record(entry: dict) -> dict: - owner_name = user_display_name(entry.get("owner_display_name"), entry.get("owner_username")) - entry["owner_name"] = owner_name - entry["is_personal"] = entry.get("visibility") == "personal" - entry["is_shared"] = entry.get("visibility") == "shared" - entry["is_mine"] = entry.get("owner_user_id") == g.user["id"] - entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Für alle") - entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "") - entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}" - entry["context_label"] = "Gemeinsam" if entry["is_shared"] else "Persönlich" - entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin" - return entry - - -def describe_records(rows) -> list[dict]: - return [describe_record(dict(row)) for row in rows] - - -def ensure_can_edit(entry: dict, error_message: str = "Diesen Eintrag kannst du gerade nicht bearbeiten.") -> None: - if not (entry.get("can_edit") or g.user["role"] == "admin"): - raise PermissionError(error_message) +def normalize_target_user_id(raw: str | None) -> int | None: + if not raw or raw == TARGET_USER_OPTIONS_DEFAULT: + return None + if not raw.isdigit(): + return None + target_id = int(raw) + allowed = {int(user["id"]) for user in get_household_users()} + return target_id if target_id in allowed else None def allowed_file(filename: str) -> bool: @@ -151,12 +180,53 @@ def save_photo(upload, current_filename: str | None = None) -> str | None: return filename +def user_display_name(display_name: str | None, username: str | None) -> str: + return display_name or username or "Haushalt" + + +def describe_record(entry: dict) -> dict: + owner_name = user_display_name(entry.get("owner_display_name"), entry.get("owner_username")) + target_name = user_display_name(entry.get("target_display_name"), entry.get("target_username")) if entry.get("target_user_id") else None + entry["owner_name"] = owner_name + entry["target_name"] = target_name + entry["is_personal"] = entry.get("visibility") == "personal" + entry["is_shared"] = entry.get("visibility") == "shared" + entry["is_mine"] = entry.get("owner_user_id") == g.user["id"] + 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" + entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin" + return entry + + +def describe_records(rows) -> list[dict]: + return [describe_record(dict(row)) for row in rows] + + +def describe_template_record(entry: dict) -> dict: + owner_name = user_display_name(entry.get("owner_display_name"), entry.get("owner_username")) + entry["owner_name"] = owner_name + entry["is_mine"] = entry.get("owner_user_id") == g.user["id"] + entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam") + entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}" + entry["can_edit"] = entry.get("visibility") == "shared" or entry["is_mine"] or g.user["role"] == "admin" + return entry + + +def ensure_can_edit(entry: dict, error_message: str = "Diesen Eintrag kannst du gerade nicht bearbeiten.") -> None: + if not (entry.get("can_edit") or g.user["role"] == "admin"): + raise PermissionError(error_message) + + def get_item(item_id: int) -> dict: item = get_db().execute( f""" SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username, EXISTS( SELECT 1 FROM shopping_entries @@ -164,6 +234,7 @@ def get_item(item_id: int) -> dict: ) AS is_on_shopping_list FROM items LEFT JOIN users AS owner ON owner.id = items.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id WHERE items.id = ? AND {visible_clause('items')} """, [item_id, *visible_params()], @@ -215,20 +286,14 @@ def attach_dayparts(items: list[dict]) -> list[dict]: grouped = defaultdict(list) for row in rows: grouped[row["item_id"]].append( - { - "id": row["id"], - "slug": row["slug"], - "name": row["name"], - } + {"id": row["id"], "slug": row["slug"], "name": row["name"]} ) - enriched = [] for item in items: item["dayparts_meta"] = grouped.get(item["id"], []) item["dayparts"] = [daypart["name"] for daypart in item["dayparts_meta"]] item["primary_daypart_id"] = item["dayparts_meta"][0]["id"] if item["dayparts_meta"] else None - enriched.append(item) - return enriched + return items def attach_components(items: list[dict]) -> list[dict]: @@ -236,12 +301,15 @@ def attach_components(items: list[dict]) -> list[dict]: if not meal_ids: for item in items: item["components"] = [] + item["component_ids"] = [] return items placeholders = ",".join("?" for _ in meal_ids) rows = get_db().execute( f""" - SELECT meal_components.meal_item_id, items.name + SELECT meal_components.meal_item_id, + meal_components.food_item_id, + items.name FROM meal_components JOIN items ON items.id = meal_components.food_item_id WHERE meal_components.meal_item_id IN ({placeholders}) @@ -251,12 +319,15 @@ def attach_components(items: list[dict]) -> list[dict]: """, [*meal_ids, current_household_id(), g.user["id"]], ).fetchall() - grouped = defaultdict(list) + grouped_names: dict[int, list[str]] = defaultdict(list) + grouped_ids: dict[int, list[int]] = defaultdict(list) for row in rows: - grouped[row["meal_item_id"]].append(row["name"]) + grouped_names[row["meal_item_id"]].append(row["name"]) + grouped_ids[row["meal_item_id"]].append(int(row["food_item_id"])) for item in items: - item["components"] = grouped.get(item["id"], []) + item["components"] = grouped_names.get(item["id"], []) + item["component_ids"] = grouped_ids.get(item["id"], []) return items @@ -269,7 +340,6 @@ def fetch_items( daypart_id: int | None = None, visibility: str | None = None, ): - database = get_db() conditions = [visible_clause("items")] params = visible_params() @@ -300,11 +370,13 @@ def fetch_items( conditions.append("items.visibility = ?") params.append(visibility) - rows = database.execute( + rows = get_db().execute( f""" SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username, EXISTS( SELECT 1 FROM shopping_entries @@ -312,6 +384,7 @@ def fetch_items( ) AS is_on_shopping_list FROM items LEFT JOIN users AS owner ON owner.id = items.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id WHERE {' AND '.join(conditions)} ORDER BY CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END, @@ -323,8 +396,8 @@ def fetch_items( return attach_components(attach_dayparts(describe_records(rows))) -def fetch_food_options(): - return fetch_items(kind="food", include_archived=True) +def fetch_food_options(query: str | None = None): + return fetch_items(kind="food", include_archived=True, query=query) def group_items_by_availability(items: list[dict]) -> list[dict]: @@ -332,18 +405,11 @@ def group_items_by_availability(items: list[dict]) -> list[dict]: for item in items: grouped[item["availability_state"]].append(item) - ordered_states = ["home", "idea", "archived"] result = [] - for state in ordered_states: + for state in ("home", "idea", "archived"): entries = grouped.get(state, []) if entries: - result.append( - { - "state": state, - "title": AVAILABILITY_LABELS[state], - "items": entries, - } - ) + result.append({"state": state, "title": AVAILABILITY_LABELS[state], "items": entries}) return result @@ -355,6 +421,9 @@ def extract_item_form_data(existing: dict | None = None) -> dict: "category": request.form.get("category", "").strip(), "note": request.form.get("note", "").strip(), "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), + "target_user_id": normalize_target_user_id(request.form.get("target_user_id")), + "target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT), + "food_search": request.form.get("food_search", "").strip(), "daypart_ids": [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()], "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(), @@ -366,17 +435,17 @@ def extract_item_form_data(existing: dict | None = None) -> dict: def create_quick_food_from_form(form_data: dict) -> int: - database = get_db() - cursor = database.execute( + cursor = get_db().execute( """ INSERT INTO items ( - household_id, owner_user_id, visibility, kind, name, category, note, created_by, updated_by + household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, created_by, updated_by ) - VALUES (?, ?, ?, 'food', ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], + form_data["target_user_id"], form_data["visibility"], form_data["quick_food_name"], form_data["quick_food_category"], @@ -387,14 +456,20 @@ def create_quick_food_from_form(form_data: dict) -> int: ) food_id = int(cursor.lastrowid) sync_item_dayparts(food_id, form_data["daypart_ids"]) - database.commit() + get_db().commit() return food_id -def add_to_shopping_list(item_id: int, user_id: int, visibility_override: str | None = None) -> bool: - database = get_db() +def add_to_shopping_list( + item_id: int, + user_id: int, + *, + visibility_override: str | None = None, + needed_for_date: str | None = None, + needed_for_daypart_id: int | None = None, +) -> bool: item = get_item(item_id) - existing = database.execute( + existing = get_db().execute( """ SELECT id FROM shopping_entries WHERE item_id = ? AND is_checked = 0 @@ -406,40 +481,114 @@ def add_to_shopping_list(item_id: int, user_id: int, visibility_override: str | visibility = normalize_visibility(visibility_override, item["visibility"]) owner_user_id = user_id if visibility == "personal" else item["owner_user_id"] - database.execute( + get_db().execute( """ - INSERT INTO shopping_entries (household_id, owner_user_id, visibility, item_id, added_by) - VALUES (?, ?, ?, ?, ?) + INSERT INTO shopping_entries ( + household_id, owner_user_id, visibility, item_id, added_by, needed_for_date, needed_for_daypart_id + ) + VALUES (?, ?, ?, ?, ?, ?, ?) """, - (current_household_id(), owner_user_id, visibility, item_id, user_id), + ( + current_household_id(), + owner_user_id, + visibility, + item_id, + user_id, + needed_for_date, + needed_for_daypart_id, + ), ) - database.commit() + get_db().commit() return True -def ensure_planned_item_is_shopped(item_id: int, user_id: int, visibility: str) -> bool: +def fetch_meal_missing_components(meal_id: int) -> list[dict]: + rows = get_db().execute( + """ + SELECT items.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username, + 0 AS is_on_shopping_list + FROM meal_components + JOIN items ON items.id = meal_components.food_item_id + LEFT JOIN users AS owner ON owner.id = items.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id + WHERE meal_components.meal_item_id = ? + AND items.household_id = ? + AND (items.visibility = 'shared' OR items.owner_user_id = ?) + AND items.availability_state != 'home' + ORDER BY LOWER(items.name) + """, + (meal_id, current_household_id(), g.user["id"]), + ).fetchall() + return attach_dayparts(describe_records(rows)) + + +def ensure_item_or_missing_components_are_shopped( + item_id: int, + user_id: int, + visibility: str, + *, + needed_for_date: str | None = None, + needed_for_daypart_id: int | None = None, +) -> dict: item = get_item(item_id) + if item["kind"] == "meal": + missing_components = fetch_meal_missing_components(item_id) + if missing_components: + added_names = [] + for component in missing_components: + added = add_to_shopping_list( + component["id"], + user_id, + visibility_override=visibility, + needed_for_date=needed_for_date, + needed_for_daypart_id=needed_for_daypart_id, + ) + if added: + added_names.append(component["name"]) + return { + "added": bool(added_names), + "count": len(added_names), + "names": added_names, + "used_components": True, + } + return {"added": False, "count": 0, "names": [], "used_components": True} + if item["availability_state"] == "home": - return False - return add_to_shopping_list(item_id, user_id, visibility_override=visibility) + return {"added": False, "count": 0, "names": [], "used_components": False} + + added = add_to_shopping_list( + item_id, + user_id, + visibility_override=visibility, + needed_for_date=needed_for_date, + needed_for_daypart_id=needed_for_daypart_id, + ) + return { + "added": added, + "count": 1 if added else 0, + "names": [item["name"]] if added else [], + "used_components": False, + } def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None: - database = get_db() - database.execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,)) + get_db().execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,)) for daypart_id in daypart_ids: - database.execute( + get_db().execute( "INSERT INTO item_dayparts (item_id, daypart_id) VALUES (?, ?)", (item_id, daypart_id), ) def sync_meal_components(meal_id: int, food_ids: list[int]) -> None: - database = get_db() - database.execute("DELETE FROM meal_components WHERE meal_item_id = ?", (meal_id,)) + get_db().execute("DELETE FROM meal_components WHERE meal_item_id = ?", (meal_id,)) visible_foods = { row["id"] - for row in database.execute( + for row in get_db().execute( f""" SELECT items.id FROM items @@ -449,15 +598,11 @@ def sync_meal_components(meal_id: int, food_ids: list[int]) -> None: ).fetchall() } for food_id in food_ids: - if food_id not in visible_foods: - continue - database.execute( - """ - INSERT INTO meal_components (meal_item_id, food_item_id) - VALUES (?, ?) - """, - (meal_id, food_id), - ) + if food_id in visible_foods: + get_db().execute( + "INSERT INTO meal_components (meal_item_id, food_item_id) VALUES (?, ?)", + (meal_id, food_id), + ) def fetch_shopping_entries(): @@ -470,12 +615,14 @@ def fetch_shopping_entries(): items.availability_state, owner.display_name AS owner_display_name, owner.username AS owner_username, - added_by_user.display_name AS added_by_display_name, - added_by_user.username AS added_by_username + target.display_name AS target_display_name, + target.username AS target_username, + dayparts.name AS needed_daypart_name FROM shopping_entries JOIN items ON items.id = shopping_entries.item_id LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id - LEFT JOIN users AS added_by_user ON added_by_user.id = shopping_entries.added_by + LEFT JOIN users AS target ON target.id = items.target_user_id + LEFT JOIN dayparts ON dayparts.id = shopping_entries.needed_for_daypart_id WHERE shopping_entries.is_checked = 0 AND {visible_clause('shopping_entries')} ORDER BY CASE shopping_entries.visibility WHEN 'shared' THEN 0 ELSE 1 END, @@ -483,7 +630,17 @@ def fetch_shopping_entries(): """, visible_params(), ).fetchall() - return describe_records(rows) + entries = describe_records(rows) + for entry in entries: + if entry.get("needed_for_date"): + try: + parsed = datetime.strptime(entry["needed_for_date"], "%Y-%m-%d").date() + entry["needed_for_label"] = parsed.strftime("%d.%m.%Y") + except ValueError: + entry["needed_for_label"] = entry["needed_for_date"] + else: + entry["needed_for_label"] = None + return entries def fetch_plan_entries_for_range(start_date: date, end_date: date): @@ -498,11 +655,14 @@ def fetch_plan_entries_for_range(start_date: date, end_date: date): dayparts.slug AS daypart_slug, dayparts.sort_order, owner.display_name AS owner_display_name, - owner.username AS owner_username + owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username FROM plan_entries JOIN items ON items.id = plan_entries.item_id JOIN dayparts ON dayparts.id = plan_entries.daypart_id LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id WHERE plan_date BETWEEN ? AND ? AND {visible_clause('plan_entries')} ORDER BY plan_date, dayparts.sort_order, items.name """, @@ -524,6 +684,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6): SELECT DISTINCT items.id, items.household_id, items.owner_user_id, + items.target_user_id, items.visibility, items.name, items.kind, @@ -532,10 +693,13 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6): items.photo_filename, items.availability_state, owner.display_name AS owner_display_name, - owner.username AS owner_username + owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username FROM plan_entries JOIN items ON items.id = plan_entries.item_id LEFT JOIN users AS owner ON owner.id = items.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id WHERE plan_entries.daypart_id = ? AND {visible_clause('items')} ORDER BY plan_entries.created_at DESC LIMIT ? @@ -557,6 +721,8 @@ def fetch_plan_candidates(daypart_id: int, query: str | None = None): SELECT items.*, owner.display_name AS owner_display_name, owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username, EXISTS( SELECT 1 FROM item_dayparts @@ -564,6 +730,7 @@ def fetch_plan_candidates(daypart_id: int, query: str | None = None): ) AS matches_daypart FROM items LEFT JOIN users AS owner ON owner.id = items.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id WHERE {' AND '.join(conditions)} ORDER BY CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END, @@ -576,6 +743,193 @@ def fetch_plan_candidates(daypart_id: int, query: str | None = None): return attach_components(attach_dayparts(describe_records(rows))) +def fetch_home_food_ids() -> set[int]: + rows = get_db().execute( + f""" + SELECT id + FROM items + WHERE kind = 'food' AND availability_state = 'home' AND {visible_clause('items')} + """, + visible_params(), + ).fetchall() + return {int(row["id"]) for row in rows} + + +def build_daypart_suggestions(daypart_id: int) -> list[dict]: + home_food_ids = fetch_home_food_ids() + suggestions: list[dict] = [] + + meals = fetch_items(kind="meal", daypart_id=daypart_id) + for meal in meals: + if not meal["component_ids"]: + continue + if all(component_id in home_food_ids for component_id in meal["component_ids"]): + suggestions.append( + { + "title": meal["name"], + "reason": "Zuhause vorhanden", + "note": "Die Bestandteile sind gerade da und passen gut dazu.", + } + ) + + archived_items = fetch_items(availability="archived", include_archived=True, daypart_id=daypart_id) + for item in archived_items[:2]: + suggestions.append( + { + "title": item["name"], + "reason": "Für später merken", + "note": "War schon einmal dabei und könnte heute wieder passen.", + } + ) + + often_used = get_db().execute( + f""" + SELECT items.name, COUNT(*) AS usage_count + FROM plan_entries + JOIN items ON items.id = plan_entries.item_id + WHERE plan_entries.daypart_id = ? AND {visible_clause('items')} + GROUP BY items.id, items.name + HAVING COUNT(*) > 1 + ORDER BY usage_count DESC, LOWER(items.name) + LIMIT 2 + """, + [daypart_id, *visible_params()], + ).fetchall() + for row in often_used: + suggestions.append( + { + "title": row["name"], + "reason": "Oft gemeinsam genutzt", + "note": "Ist in diesem Zeitfenster schon öfter aufgetaucht.", + } + ) + + deduped = [] + seen = set() + for suggestion in suggestions: + key = suggestion["title"] + if key in seen: + continue + seen.add(key) + deduped.append(suggestion) + if len(deduped) >= 4: + break + return deduped + + +def build_dashboard_hints(today: date) -> list[str]: + hints: list[str] = [] + tomorrow = today + timedelta(days=1) + breakfast = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM plan_entries + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} + """, + [tomorrow.isoformat(), *visible_params()], + ).fetchone() + if int(breakfast["count"]) == 0: + hints.append("Für morgen ist noch kein Frühstück eingeplant.") + + dinner_home = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM items + JOIN item_dayparts ON item_dayparts.item_id = items.id + JOIN dayparts ON dayparts.id = item_dayparts.daypart_id + WHERE items.availability_state = 'home' + AND dayparts.slug = 'dinner' + AND {visible_clause('items')} + """, + visible_params(), + ).fetchone() + if int(dinner_home["count"]) > 0: + hints.append("Zuhause ist bereits etwas für Abendessen da.") + + old_template = get_db().execute( + f""" + SELECT name + FROM day_templates + WHERE {visible_clause('day_templates')} + AND last_used_at IS NOT NULL + AND DATE(last_used_at) <= DATE('now', '-21 day') + ORDER BY last_used_at ASC + LIMIT 1 + """, + visible_params(), + ).fetchone() + if old_template: + hints.append(f"Die Tagesvorlage „{old_template['name']}“ wurde länger nicht genutzt.") + return hints[:3] + + +def build_day_hints(selected_date: date) -> list[str]: + hints: list[str] = [] + breakfast_count = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM plan_entries + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + WHERE plan_entries.plan_date = ? AND dayparts.slug = 'breakfast' AND {visible_clause('plan_entries')} + """, + [selected_date.isoformat(), *visible_params()], + ).fetchone() + if int(breakfast_count["count"]) == 0: + hints.append("Für diesen Tag ist noch kein Frühstück eingeplant.") + + afternoon_options = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM items + JOIN item_dayparts ON item_dayparts.item_id = items.id + JOIN dayparts ON dayparts.id = item_dayparts.daypart_id + WHERE items.availability_state != 'archived' + AND dayparts.slug = 'afternoon-snack' + AND {visible_clause('items')} + """, + visible_params(), + ).fetchone() + if int(afternoon_options["count"]) < 2: + hints.append("Für den Nachmittag gibt es gerade wenig eingeplante Optionen.") + return hints[:3] + + +def build_week_hints(week_start: date) -> list[str]: + hints: list[str] = [] + week_end = week_start + timedelta(days=6) + breakfast_days = get_db().execute( + f""" + SELECT COUNT(DISTINCT plan_entries.plan_date) AS count + FROM plan_entries + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + WHERE plan_entries.plan_date BETWEEN ? AND ? + AND dayparts.slug = 'breakfast' + AND {visible_clause('plan_entries')} + """, + [week_start.isoformat(), week_end.isoformat(), *visible_params()], + ).fetchone() + missing_breakfasts = 7 - int(breakfast_days["count"]) + if missing_breakfasts > 0: + hints.append(f"In dieser Woche sind noch {missing_breakfasts} Tage ohne Frühstücksplan.") + + home_dinners = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM items + JOIN item_dayparts ON item_dayparts.item_id = items.id + JOIN dayparts ON dayparts.id = item_dayparts.daypart_id + WHERE items.availability_state = 'home' + AND dayparts.slug = 'dinner' + AND {visible_clause('items')} + """, + visible_params(), + ).fetchone() + if int(home_dinners["count"]) > 0: + hints.append("Zuhause sind bereits Dinge da, die gut zu Abendessen passen.") + return hints[:3] + + def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: int | None): sections = [] if selected_daypart_id: @@ -585,36 +939,23 @@ def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: { "title": selected_daypart["name"] if selected_daypart else "Ausgewählte Tageszeit", "items": matching_items, - "slug": selected_daypart["slug"] if selected_daypart else "selected", } ) return sections for daypart in dayparts: matching_items = [item for item in items if any(dp["id"] == daypart["id"] for dp in item["dayparts_meta"])] - sections.append( - { - "title": daypart["name"], - "items": matching_items, - "slug": daypart["slug"], - } - ) + sections.append({"title": daypart["name"], "items": matching_items}) anytime_items = [item for item in items if not item["dayparts_meta"]] if anytime_items: - sections.append( - { - "title": "Ohne feste Tageszeit", - "items": anytime_items, - "slug": "anytime", - } - ) + sections.append({"title": "Ohne feste Tageszeit", "items": anytime_items}) return sections def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]: - seen_ids = set() result = [] + seen_ids = set() for item in items: if item["id"] in seen_ids: continue @@ -626,36 +967,55 @@ def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]: def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None): - dayparts = get_dayparts() - day_entries = fetch_day_plan_entries(selected_date) sections = [] - - for daypart in dayparts: + day_entries = fetch_day_plan_entries(selected_date) + for daypart in get_dayparts(): candidates = fetch_plan_candidates(daypart["id"]) home_candidates = [item for item in candidates if item["availability_state"] == "home"] - matching_candidates = [ - item for item in candidates if any(meta["id"] == daypart["id"] for meta in item["dayparts_meta"]) - ] + matching_candidates = [item for item in candidates if any(meta["id"] == daypart["id"] for meta in item["dayparts_meta"])] recent_candidates = fetch_recent_plan_items(daypart["id"]) quick_items = dedupe_items(home_candidates + recent_candidates + matching_candidates, limit=6) + entries = day_entries.get((selected_date.isoformat(), daypart["id"]), []) sections.append( { "daypart": daypart, - "entries": day_entries.get((selected_date.isoformat(), daypart["id"]), []), + "entries": entries, "candidates": candidates, "quick_items": quick_items, + "suggestions": build_daypart_suggestions(daypart["id"]), "selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None, "is_open": selected_daypart_id == daypart["id"], - "summary_items": [entry["item_name"] for entry in day_entries.get((selected_date.isoformat(), daypart["id"]), [])][:2], + "summary_items": [entry["item_name"] for entry in entries][:2], "default_visibility": "shared", } ) return sections +def build_template_day_sections(selected_map: dict[int, list[int]] | None = None): + selected_map = selected_map or {} + sections = [] + for daypart in get_dayparts(): + candidates = fetch_plan_candidates(int(daypart["id"])) + quick_items = dedupe_items( + [item for item in candidates if item["availability_state"] == "home"] + candidates, + limit=10, + ) + quick_ids = {item["id"] for item in quick_items} + sections.append( + { + "daypart": daypart, + "candidates": candidates, + "quick_items": quick_items, + "list_items": [item for item in candidates if item["id"] not in quick_ids], + "selected_ids": selected_map.get(int(daypart["id"]), []), + } + ) + return sections + + def fetch_week_cards(week_start: date): days = [week_start + timedelta(days=index) for index in range(7)] - dayparts = get_dayparts() grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6)) cards = [] for current_day in days: @@ -663,17 +1023,11 @@ def fetch_week_cards(week_start: date): planned_count = 0 preview_items = [] slots = [] - for daypart in dayparts: + for daypart in get_dayparts(): slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), []) slots.append({"daypart": dict(daypart), "entries": slot_entries}) if slot_entries: - filled_dayparts.append( - { - "id": daypart["id"], - "name": daypart["name"], - "count": len(slot_entries), - } - ) + filled_dayparts.append({"id": daypart["id"], "name": daypart["name"], "count": len(slot_entries)}) planned_count += len(slot_entries) preview_items.extend(entry["item_name"] for entry in slot_entries[:2]) cards.append( @@ -696,6 +1050,434 @@ def count_visible_items(availability_state: str) -> int: return int(row["count"]) +def fetch_day_templates(query: str | None = None, visibility: str | None = None) -> list[dict]: + conditions = [visible_clause("day_templates")] + params = visible_params() + if query: + conditions.append("LOWER(day_templates.name) LIKE ?") + params.append(f"%{query.lower()}%") + if visibility: + conditions.append("day_templates.visibility = ?") + params.append(visibility) + rows = get_db().execute( + f""" + SELECT day_templates.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username, + ( + SELECT COUNT(*) + FROM day_template_entries + WHERE day_template_entries.day_template_id = day_templates.id + ) AS entry_count + FROM day_templates + LEFT JOIN users AS owner ON owner.id = day_templates.owner_user_id + WHERE {' AND '.join(conditions)} + ORDER BY LOWER(day_templates.name) + """, + params, + ).fetchall() + return [describe_template_record(dict(row)) for row in rows] + + +def get_day_template(template_id: int) -> dict: + row = get_db().execute( + f""" + SELECT day_templates.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username + FROM day_templates + LEFT JOIN users AS owner ON owner.id = day_templates.owner_user_id + WHERE day_templates.id = ? AND {visible_clause('day_templates')} + """, + [template_id, *visible_params()], + ).fetchone() + if row is None: + raise ValueError("Die Tagesvorlage wurde nicht gefunden.") + return describe_template_record(dict(row)) + + +def get_day_template_selected_map(template_id: int) -> dict[int, list[int]]: + rows = get_db().execute( + """ + SELECT daypart_id, item_id + FROM day_template_entries + WHERE day_template_id = ? + ORDER BY sort_order, id + """, + (template_id,), + ).fetchall() + grouped: dict[int, list[int]] = defaultdict(list) + for row in rows: + grouped[int(row["daypart_id"])].append(int(row["item_id"])) + return grouped + + +def sync_day_template_entries(template_id: int, selected_map: dict[int, list[int]]) -> None: + get_db().execute("DELETE FROM day_template_entries WHERE day_template_id = ?", (template_id,)) + for daypart in get_dayparts(): + for sort_order, item_id in enumerate(selected_map.get(int(daypart["id"]), []), start=10): + get_db().execute( + """ + INSERT INTO day_template_entries (day_template_id, daypart_id, item_id, sort_order) + VALUES (?, ?, ?, ?) + """, + (template_id, daypart["id"], item_id, sort_order), + ) + + +def day_template_form_data(template: dict | None = None, source_date: date | None = None) -> dict: + selected_map: dict[int, list[int]] = defaultdict(list) + if template: + selected_map.update(get_day_template_selected_map(template["id"])) + elif source_date: + for (plan_date_key, daypart_id), entries in fetch_day_plan_entries(source_date).items(): + if plan_date_key == source_date.isoformat(): + selected_map[int(daypart_id)] = [int(entry["item_id"]) for entry in entries] + + form_data: dict[str, object] = { + "name": template["name"] if template else "", + "description": template["description"] if template else "", + "visibility": template["visibility"] if template else "shared", + "selected_map": {key: value[:] for key, value in selected_map.items()}, + } + return form_data + + +def extract_day_template_form_data(existing: dict | None = None) -> dict: + form_data = existing or {} + selected_map: dict[int, list[int]] = {} + for daypart in get_dayparts(): + selected_map[int(daypart["id"])] = [ + int(value) + for value in request.form.getlist(f"daypart_{daypart['id']}_item_ids") + if value.isdigit() + ] + form_data.update( + { + "name": request.form.get("name", "").strip(), + "description": request.form.get("description", "").strip(), + "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), + "selected_map": selected_map, + } + ) + return form_data + + +def apply_day_template(template_id: int, selected_date: date) -> int: + template = get_day_template(template_id) + entries_map = get_day_template_selected_map(template_id) + inserted = 0 + for daypart_id, item_ids in entries_map.items(): + for item_id in item_ids: + existing = get_db().execute( + """ + SELECT id + FROM plan_entries + WHERE plan_date = ? AND daypart_id = ? AND item_id = ? AND visibility = ? + """, + (selected_date.isoformat(), daypart_id, item_id, template["visibility"]), + ).fetchone() + if existing: + continue + get_db().execute( + """ + INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + template["visibility"], + selected_date.isoformat(), + daypart_id, + item_id, + g.user["id"], + ), + ) + insert_result = ensure_item_or_missing_components_are_shopped( + item_id, + g.user["id"], + template["visibility"], + needed_for_date=selected_date.isoformat(), + needed_for_daypart_id=daypart_id, + ) + inserted += 1 + get_db().commit() + get_db().execute( + "UPDATE day_templates SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", + (template_id,), + ) + get_db().commit() + return inserted + + +def create_day_template_snapshot(name: str, description: str, visibility: str, source_date: date) -> int | None: + selected_map = day_template_form_data(source_date=source_date)["selected_map"] + if not any(selected_map.values()): + return None + cursor = get_db().execute( + """ + INSERT INTO day_templates (household_id, owner_user_id, visibility, name, description) + VALUES (?, ?, ?, ?, ?) + """, + (current_household_id(), g.user["id"], visibility, name, description), + ) + template_id = int(cursor.lastrowid) + sync_day_template_entries(template_id, selected_map) + return template_id + + +def fetch_week_templates(query: str | None = None, visibility: str | None = None) -> list[dict]: + conditions = [visible_clause("week_templates")] + params = visible_params() + if query: + conditions.append("LOWER(week_templates.name) LIKE ?") + params.append(f"%{query.lower()}%") + if visibility: + conditions.append("week_templates.visibility = ?") + params.append(visibility) + rows = get_db().execute( + f""" + SELECT week_templates.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username, + ( + SELECT COUNT(*) + FROM week_template_days + WHERE week_template_days.week_template_id = week_templates.id + ) AS day_count + FROM week_templates + LEFT JOIN users AS owner ON owner.id = week_templates.owner_user_id + WHERE {' AND '.join(conditions)} + ORDER BY LOWER(week_templates.name) + """, + params, + ).fetchall() + return [describe_template_record(dict(row)) for row in rows] + + +def get_week_template(template_id: int) -> dict: + row = get_db().execute( + f""" + SELECT week_templates.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username + FROM week_templates + LEFT JOIN users AS owner ON owner.id = week_templates.owner_user_id + WHERE week_templates.id = ? AND {visible_clause('week_templates')} + """, + [template_id, *visible_params()], + ).fetchone() + if row is None: + raise ValueError("Die Wochenvorlage wurde nicht gefunden.") + return describe_template_record(dict(row)) + + +def get_week_template_selected_map(template_id: int) -> dict[int, int]: + rows = get_db().execute( + """ + SELECT weekday_index, day_template_id + FROM week_template_days + WHERE week_template_id = ? + ORDER BY weekday_index + """, + (template_id,), + ).fetchall() + return {int(row["weekday_index"]): int(row["day_template_id"]) for row in rows} + + +def week_template_form_data(template: dict | None = None, source_week: date | None = None) -> dict: + selected_map = get_week_template_selected_map(template["id"]) if template else {} + source_days = {} + if source_week: + for weekday_index in range(7): + source_days[weekday_index] = fetch_day_plan_entries(source_week + timedelta(days=weekday_index)) + return { + "name": template["name"] if template else "", + "description": template["description"] if template else "", + "visibility": template["visibility"] if template else "shared", + "selected_map": selected_map, + "source_week": source_week.isoformat() if source_week else "", + "copy_from_source": { + index: bool(source_days.get(index)) + for index in range(7) + }, + } + + +def extract_week_template_form_data(existing: dict | None = None) -> dict: + form_data = existing or {} + selected_map: dict[int, int | None] = {} + copy_from_source: dict[int, bool] = {} + for weekday_index in range(7): + raw_value = request.form.get(f"weekday_{weekday_index}_day_template_id", "").strip() + selected_map[weekday_index] = int(raw_value) if raw_value.isdigit() else None + copy_from_source[weekday_index] = request.form.get(f"weekday_{weekday_index}_copy_source") == "1" + form_data.update( + { + "name": request.form.get("name", "").strip(), + "description": request.form.get("description", "").strip(), + "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), + "selected_map": selected_map, + "source_week": request.form.get("source_week", "").strip(), + "copy_from_source": copy_from_source, + } + ) + return form_data + + +def sync_week_template_days(template_id: int, selected_map: dict[int, int | None]) -> None: + get_db().execute("DELETE FROM week_template_days WHERE week_template_id = ?", (template_id,)) + for weekday_index, day_template_id in selected_map.items(): + if day_template_id: + get_db().execute( + """ + INSERT INTO week_template_days (week_template_id, weekday_index, day_template_id) + VALUES (?, ?, ?) + """, + (template_id, weekday_index, day_template_id), + ) + + +def apply_week_template(template_id: int, week_start: date) -> int: + selected_map = get_week_template_selected_map(template_id) + inserted = 0 + for weekday_index, day_template_id in selected_map.items(): + inserted += apply_day_template(day_template_id, week_start + timedelta(days=weekday_index)) + get_db().execute( + "UPDATE week_templates SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", + (template_id,), + ) + get_db().commit() + return inserted + + +def fetch_item_sets(query: str | None = None, visibility: str | None = None) -> list[dict]: + conditions = [visible_clause("item_sets")] + params = visible_params() + if query: + conditions.append("LOWER(item_sets.name) LIKE ?") + params.append(f"%{query.lower()}%") + if visibility: + conditions.append("item_sets.visibility = ?") + params.append(visibility) + rows = get_db().execute( + f""" + SELECT item_sets.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username, + ( + SELECT COUNT(*) + FROM item_set_items + WHERE item_set_items.item_set_id = item_sets.id + ) AS item_count + FROM item_sets + LEFT JOIN users AS owner ON owner.id = item_sets.owner_user_id + WHERE {' AND '.join(conditions)} + ORDER BY LOWER(item_sets.name) + """, + params, + ).fetchall() + return [describe_template_record(dict(row)) for row in rows] + + +def get_item_set(set_id: int) -> dict: + row = get_db().execute( + f""" + SELECT item_sets.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username + FROM item_sets + LEFT JOIN users AS owner ON owner.id = item_sets.owner_user_id + WHERE item_sets.id = ? AND {visible_clause('item_sets')} + """, + [set_id, *visible_params()], + ).fetchone() + if row is None: + raise ValueError("Das Paket wurde nicht gefunden.") + return describe_template_record(dict(row)) + + +def get_item_set_selected_ids(set_id: int) -> list[int]: + rows = get_db().execute( + """ + SELECT item_id + FROM item_set_items + WHERE item_set_id = ? + ORDER BY sort_order, id + """, + (set_id,), + ).fetchall() + return [int(row["item_id"]) for row in rows] + + +def sync_item_set_items(set_id: int, item_ids: list[int]) -> None: + get_db().execute("DELETE FROM item_set_items WHERE item_set_id = ?", (set_id,)) + for sort_order, item_id in enumerate(item_ids, start=10): + get_db().execute( + """ + INSERT OR IGNORE INTO item_set_items (item_set_id, item_id, sort_order) + VALUES (?, ?, ?) + """, + (set_id, item_id, sort_order), + ) + + +def extract_item_set_form_data(existing: dict | None = None) -> dict: + form_data = existing or {} + form_data.update( + { + "name": request.form.get("name", "").strip(), + "description": request.form.get("description", "").strip(), + "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), + "item_ids": [int(value) for value in request.form.getlist("item_ids") if value.isdigit()], + "item_search": request.form.get("item_search", "").strip(), + } + ) + return form_data + + +def apply_item_set_to_shopping(set_id: int) -> dict: + item_set = get_item_set(set_id) + selected_ids = get_item_set_selected_ids(set_id) + added_names: list[str] = [] + for item_id in selected_ids: + result = ensure_item_or_missing_components_are_shopped( + item_id, + g.user["id"], + item_set["visibility"], + ) + added_names.extend(result["names"]) + get_db().execute( + "UPDATE item_sets SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?", + (set_id,), + ) + get_db().commit() + return {"count": len(added_names), "names": added_names} + + +def render_item_form(kind: str, *, item: dict | None, form_data: dict): + food_search = form_data.get("food_search") or None + foods = fetch_food_options(query=food_search if kind == "meal" else None) + return render_template( + "items/form.html", + kind=kind, + item=item, + dayparts=get_dayparts(), + food_groups=group_items_by_availability(foods), + categories=get_category_options( + form_data.get("category") or form_data.get("quick_food_category") + ), + form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, + target_user_options=get_target_user_options(), + ) + + +def planner_template_options(): + return fetch_day_templates() + + @main_bp.get("/") @login_required def dashboard(): @@ -721,13 +1503,343 @@ def dashboard(): home_items=home_items[:8], today=today, week_cards=week_cards[:3], + dashboard_hints=build_dashboard_hints(today), + day_templates=fetch_day_templates()[:3], + week_templates=fetch_week_templates()[:3], ) -@main_bp.get("/more") +@main_bp.get("/templates") @login_required -def more_view(): - return render_template("more.html") +def template_library(): + query = request.args.get("q", "").strip() + selected_visibility = request.args.get("visibility", "").strip() + visibility = selected_visibility or None + day_templates = fetch_day_templates(query=query or None, visibility=visibility) + week_templates = fetch_week_templates(query=query or None, visibility=visibility) + item_sets = fetch_item_sets(query=query or None, visibility=visibility) + template_hints = build_dashboard_hints(date.today()) + return render_template( + "library/index.html", + query=query, + selected_visibility=selected_visibility, + visibility_options=VISIBILITY_FILTER_OPTIONS, + day_templates=day_templates, + week_templates=week_templates, + item_sets=item_sets, + template_hints=template_hints, + ) + + +@main_bp.route("/templates/day/new", methods=("GET", "POST")) +@login_required +def day_template_create(): + source_date = parse_plan_date(request.values.get("source_date"), fallback=None) if request.values.get("source_date") else None + form_data = day_template_form_data(source_date=source_date) + if request.method == "POST": + form_data = extract_day_template_form_data(form_data) + if not form_data["name"]: + flash("Bitte einen Namen für die Tagesvorlage eintragen.", "error") + else: + cursor = get_db().execute( + """ + INSERT INTO day_templates (household_id, owner_user_id, visibility, name, description) + VALUES (?, ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + form_data["visibility"], + form_data["name"], + form_data["description"], + ), + ) + template_id = int(cursor.lastrowid) + sync_day_template_entries(template_id, form_data["selected_map"]) + get_db().commit() + flash("Die Tagesvorlage wurde gespeichert.", "success") + return redirect(url_for("main.template_library")) + return render_template( + "library/day_form.html", + template=None, + form_data=form_data, + dayparts=get_dayparts(), + daypart_sections=build_template_day_sections(form_data["selected_map"]), + visibility_options=VISIBILITY_FORM_OPTIONS, + name_suggestions=DAY_TEMPLATE_NAME_SUGGESTIONS, + source_date=source_date, + ) + + +@main_bp.route("/templates/day//edit", methods=("GET", "POST")) +@login_required +def day_template_edit(template_id: int): + try: + template = get_day_template(template_id) + ensure_can_edit(template, "Diese Tagesvorlage kannst du gerade nicht bearbeiten.") + except (ValueError, PermissionError) as exc: + flash(str(exc), "error") + return redirect(url_for("main.template_library")) + + form_data = day_template_form_data(template=template) + if request.method == "POST": + form_data = extract_day_template_form_data(form_data) + if not form_data["name"]: + flash("Bitte einen Namen für die Tagesvorlage eintragen.", "error") + else: + get_db().execute( + """ + UPDATE day_templates + SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + form_data["name"], + form_data["description"], + form_data["visibility"], + template_id, + ), + ) + sync_day_template_entries(template_id, form_data["selected_map"]) + get_db().commit() + flash("Die Tagesvorlage wurde aktualisiert.", "success") + return redirect(url_for("main.template_library")) + return render_template( + "library/day_form.html", + template=template, + form_data=form_data, + dayparts=get_dayparts(), + daypart_sections=build_template_day_sections(form_data["selected_map"]), + visibility_options=VISIBILITY_FORM_OPTIONS, + name_suggestions=DAY_TEMPLATE_NAME_SUGGESTIONS, + source_date=None, + ) + + +@main_bp.post("/templates/day//apply") +@login_required +def day_template_apply(template_id: int): + selected_date = parse_plan_date(request.form.get("target_date")) + inserted = apply_day_template(template_id, selected_date) + flash(f"Die Tagesvorlage wurde angewendet und hat {inserted} Einträge ergänzt.", "success") + return redirect(url_for("main.planner_day", date=selected_date.isoformat())) + + +@main_bp.route("/templates/week/new", methods=("GET", "POST")) +@login_required +def week_template_create(): + source_week_raw = request.values.get("source_week", "").strip() + source_week = parse_week_start(source_week_raw) if source_week_raw else None + form_data = week_template_form_data(source_week=source_week) + if request.method == "POST": + form_data = extract_week_template_form_data(form_data) + if not form_data["name"]: + flash("Bitte einen Namen für die Wochenvorlage eintragen.", "error") + else: + cursor = get_db().execute( + """ + INSERT INTO week_templates (household_id, owner_user_id, visibility, name, description) + VALUES (?, ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + form_data["visibility"], + form_data["name"], + form_data["description"], + ), + ) + template_id = int(cursor.lastrowid) + selected_map = dict(form_data["selected_map"]) + if form_data["source_week"]: + source_start = parse_week_start(form_data["source_week"]) + for weekday_index in range(7): + if selected_map.get(weekday_index): + continue + if form_data["copy_from_source"].get(weekday_index): + snapshot_date = source_start + timedelta(days=weekday_index) + snapshot_name = f"{form_data['name']} · {WEEKDAY_LABELS[weekday_index]}" + snapshot_id = create_day_template_snapshot( + snapshot_name, + f"Automatisch aus der Woche {source_start.strftime('%d.%m.%Y')} übernommen.", + form_data["visibility"], + snapshot_date, + ) + if snapshot_id: + selected_map[weekday_index] = snapshot_id + sync_week_template_days(template_id, selected_map) + get_db().commit() + flash("Die Wochenvorlage wurde gespeichert.", "success") + return redirect(url_for("main.template_library")) + return render_template( + "library/week_form.html", + template=None, + form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, + name_suggestions=WEEK_TEMPLATE_NAME_SUGGESTIONS, + day_templates=fetch_day_templates(), + weekday_labels=WEEKDAY_LABELS, + source_week=source_week, + ) + + +@main_bp.route("/templates/week//edit", methods=("GET", "POST")) +@login_required +def week_template_edit(template_id: int): + try: + template = get_week_template(template_id) + ensure_can_edit(template, "Diese Wochenvorlage kannst du gerade nicht bearbeiten.") + except (ValueError, PermissionError) as exc: + flash(str(exc), "error") + return redirect(url_for("main.template_library")) + + form_data = week_template_form_data(template=template) + if request.method == "POST": + form_data = extract_week_template_form_data(form_data) + if not form_data["name"]: + flash("Bitte einen Namen für die Wochenvorlage eintragen.", "error") + else: + get_db().execute( + """ + UPDATE week_templates + SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + form_data["name"], + form_data["description"], + form_data["visibility"], + template_id, + ), + ) + sync_week_template_days(template_id, form_data["selected_map"]) + get_db().commit() + flash("Die Wochenvorlage wurde aktualisiert.", "success") + return redirect(url_for("main.template_library")) + return render_template( + "library/week_form.html", + template=template, + form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, + name_suggestions=WEEK_TEMPLATE_NAME_SUGGESTIONS, + day_templates=fetch_day_templates(), + weekday_labels=WEEKDAY_LABELS, + source_week=None, + ) + + +@main_bp.post("/templates/week//apply") +@login_required +def week_template_apply(template_id: int): + week_start = parse_week_start(request.form.get("target_week")) + inserted = apply_week_template(template_id, week_start) + flash(f"Die Wochenvorlage wurde angewendet und hat {inserted} Einträge ergänzt.", "success") + return redirect(url_for("main.planner", week=week_start.isoformat())) + + +@main_bp.route("/templates/set/new", methods=("GET", "POST")) +@login_required +def item_set_create(): + form_data = { + "name": "", + "description": "", + "visibility": "shared", + "item_ids": [], + "item_search": "", + } + if request.method == "POST": + form_data = extract_item_set_form_data(form_data) + if not form_data["name"]: + flash("Bitte einen Namen für das Paket eintragen.", "error") + else: + cursor = get_db().execute( + """ + INSERT INTO item_sets (household_id, owner_user_id, visibility, name, description) + VALUES (?, ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + form_data["visibility"], + form_data["name"], + form_data["description"], + ), + ) + set_id = int(cursor.lastrowid) + sync_item_set_items(set_id, form_data["item_ids"]) + get_db().commit() + flash("Das Paket wurde gespeichert.", "success") + return redirect(url_for("main.template_library")) + items = fetch_items(include_archived=False, query=form_data["item_search"] or None) + return render_template( + "library/set_form.html", + item_set=None, + form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, + name_suggestions=ITEM_SET_NAME_SUGGESTIONS, + item_groups=group_items_by_availability(items), + ) + + +@main_bp.route("/templates/set//edit", methods=("GET", "POST")) +@login_required +def item_set_edit(set_id: int): + try: + item_set = get_item_set(set_id) + ensure_can_edit(item_set, "Dieses Paket kannst du gerade nicht bearbeiten.") + except (ValueError, PermissionError) as exc: + flash(str(exc), "error") + return redirect(url_for("main.template_library")) + + form_data = { + "name": item_set["name"], + "description": item_set["description"] or "", + "visibility": item_set["visibility"], + "item_ids": get_item_set_selected_ids(set_id), + "item_search": "", + } + if request.method == "POST": + form_data = extract_item_set_form_data(form_data) + if not form_data["name"]: + flash("Bitte einen Namen für das Paket eintragen.", "error") + else: + get_db().execute( + """ + UPDATE item_sets + SET name = ?, description = ?, visibility = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + form_data["name"], + form_data["description"], + form_data["visibility"], + set_id, + ), + ) + sync_item_set_items(set_id, form_data["item_ids"]) + get_db().commit() + flash("Das Paket wurde aktualisiert.", "success") + return redirect(url_for("main.template_library")) + items = fetch_items(include_archived=False, query=form_data["item_search"] or None) + return render_template( + "library/set_form.html", + item_set=item_set, + form_data=form_data, + visibility_options=VISIBILITY_FORM_OPTIONS, + name_suggestions=ITEM_SET_NAME_SUGGESTIONS, + item_groups=group_items_by_availability(items), + ) + + +@main_bp.post("/templates/set//apply") +@login_required +def item_set_apply(set_id: int): + result = apply_item_set_to_shopping(set_id) + if result["count"]: + flash(f"Das Paket wurde auf die Einkaufsliste übernommen: {', '.join(result['names'][:4])}.", "success") + else: + flash("Das Paket ist bereits vollständig auf der Einkaufsliste oder zuhause vorhanden.", "info") + return redirect(url_for("main.shopping_list")) @main_bp.route("/items/") @@ -771,15 +1883,14 @@ def item_create(kind: str): if kind not in ITEM_KIND_LABELS: return redirect(url_for("main.dashboard")) - database = get_db() - dayparts = get_dayparts() - foods = fetch_food_options() - food_groups = group_items_by_availability(foods) form_data = { "name": "", "category": "", "note": "", "visibility": "shared", + "target_user_id": None, + "target_user_raw": TARGET_USER_OPTIONS_DEFAULT, + "food_search": "", "daypart_ids": [], "component_ids": [], "quick_food_name": "", @@ -790,7 +1901,9 @@ def item_create(kind: str): if request.method == "POST": form_action = request.form.get("form_action", "save_item") form_data = extract_item_form_data(form_data) - name = form_data["name"] + + if kind == "meal" and form_action == "filter_foods": + return render_item_form(kind, item=None, form_data=form_data) if kind == "meal" and form_action == "quick_add_food": if not form_data["quick_food_name"]: @@ -799,26 +1912,14 @@ def item_create(kind: str): new_food_id = create_quick_food_from_form(form_data) if new_food_id not in form_data["component_ids"]: form_data["component_ids"].append(new_food_id) - form_data["component_ids"] = sorted(form_data["component_ids"]) form_data["quick_food_name"] = "" form_data["quick_food_category"] = "" form_data["quick_food_note"] = "" - foods = fetch_food_options() - food_groups = group_items_by_availability(foods) flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success") - return render_template( - "items/form.html", - kind=kind, - item=None, - dayparts=dayparts, - food_groups=food_groups, - categories=CATEGORIES, - form_data=form_data, - visibility_options=VISIBILITY_FORM_OPTIONS, - ) + return render_item_form(kind, item=None, form_data=form_data) error = None - if not name: + if not form_data["name"]: error = "Bitte einen Namen eintragen." photo_filename = None @@ -829,19 +1930,20 @@ def item_create(kind: str): error = str(exc) if error is None: - cursor = database.execute( + cursor = get_db().execute( """ INSERT INTO items ( - household_id, owner_user_id, visibility, kind, name, category, note, photo_filename, created_by, updated_by + household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, photo_filename, created_by, updated_by ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), g.user["id"], + form_data["target_user_id"], form_data["visibility"], kind, - name, + form_data["name"], form_data["category"], form_data["note"], photo_filename, @@ -853,28 +1955,17 @@ def item_create(kind: str): sync_item_dayparts(item_id, form_data["daypart_ids"]) if kind == "meal": sync_meal_components(item_id, form_data["component_ids"]) - database.commit() + get_db().commit() flash(f"{ITEM_KIND_SINGULAR_LABELS[kind]} wurde angelegt.", "success") return redirect(url_for("main.item_list", kind=kind)) - flash(error, "error") - return render_template( - "items/form.html", - kind=kind, - item=None, - dayparts=dayparts, - food_groups=food_groups, - categories=CATEGORIES, - form_data=form_data, - visibility_options=VISIBILITY_FORM_OPTIONS, - ) + return render_item_form(kind, item=None, form_data=form_data) @main_bp.route("/items//edit", methods=("GET", "POST")) @login_required def item_edit(item_id: int): - database = get_db() try: item = get_item(item_id) ensure_can_edit(item) @@ -882,17 +1973,16 @@ def item_edit(item_id: int): flash(str(exc), "error") return redirect(url_for("main.dashboard")) - kind = item["kind"] - dayparts = get_dayparts() - foods = fetch_food_options() - food_groups = group_items_by_availability(foods) form_data = { "name": item["name"], "category": item["category"] or "", "note": item["note"] or "", "visibility": item["visibility"], + "target_user_id": item["target_user_id"], + "target_user_raw": str(item["target_user_id"]) if item["target_user_id"] else TARGET_USER_OPTIONS_DEFAULT, + "food_search": "", "daypart_ids": get_item_daypart_ids(item_id), - "component_ids": get_meal_component_ids(item_id) if kind == "meal" else [], + "component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [], "quick_food_name": "", "quick_food_category": "", "quick_food_note": "", @@ -901,35 +1991,25 @@ def item_edit(item_id: int): if request.method == "POST": form_action = request.form.get("form_action", "save_item") form_data = extract_item_form_data(form_data) - name = form_data["name"] - if kind == "meal" and form_action == "quick_add_food": + if item["kind"] == "meal" and form_action == "filter_foods": + return render_item_form(item["kind"], item=item, form_data=form_data) + + if item["kind"] == "meal" and form_action == "quick_add_food": if not form_data["quick_food_name"]: flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error") else: new_food_id = create_quick_food_from_form(form_data) if new_food_id not in form_data["component_ids"]: form_data["component_ids"].append(new_food_id) - form_data["component_ids"] = sorted(form_data["component_ids"]) form_data["quick_food_name"] = "" form_data["quick_food_category"] = "" form_data["quick_food_note"] = "" - foods = fetch_food_options() - food_groups = group_items_by_availability(foods) flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success") - return render_template( - "items/form.html", - kind=kind, - item=item, - dayparts=dayparts, - food_groups=food_groups, - categories=CATEGORIES, - form_data=form_data, - visibility_options=VISIBILITY_FORM_OPTIONS, - ) + return render_item_form(item["kind"], item=item, form_data=form_data) error = None - if not name: + if not form_data["name"]: error = "Bitte einen Namen eintragen." photo_filename = item["photo_filename"] @@ -940,47 +2020,39 @@ def item_edit(item_id: int): error = str(exc) if error is None: - database.execute( + get_db().execute( """ UPDATE items SET name = ?, category = ?, note = ?, visibility = ?, + target_user_id = ?, photo_filename = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, ( - name, + form_data["name"], form_data["category"], form_data["note"], form_data["visibility"], + form_data["target_user_id"], photo_filename, g.user["id"], item_id, ), ) sync_item_dayparts(item_id, form_data["daypart_ids"]) - if kind == "meal": + if item["kind"] == "meal": sync_meal_components(item_id, form_data["component_ids"]) - database.commit() + get_db().commit() flash("Der Eintrag wurde aktualisiert.", "success") - return redirect(url_for("main.item_list", kind=kind)) - + return redirect(url_for("main.item_list", kind=item["kind"])) flash(error, "error") - return render_template( - "items/form.html", - kind=kind, - item=item, - dayparts=dayparts, - food_groups=food_groups, - categories=CATEGORIES, - form_data=form_data, - visibility_options=VISIBILITY_FORM_OPTIONS, - ) + return render_item_form(item["kind"], item=item, form_data=form_data) @main_bp.post("/items//shopping") @@ -992,11 +2064,18 @@ def item_add_to_shopping(item_id: int): flash(str(exc), "error") return redirect(request.referrer or url_for("main.shopping_list")) - added = add_to_shopping_list(item_id, g.user["id"], visibility_override=item["visibility"]) - if added: - flash(f"{item['name']} steht jetzt auf der Einkaufsliste.", "success") + result = ensure_item_or_missing_components_are_shopped( + item_id, + g.user["id"], + item["visibility"], + ) + if result["count"]: + if result["used_components"]: + flash(f"Für „{item['name']}“ wurden fehlende Lebensmittel auf die Einkaufsliste gesetzt.", "success") + else: + flash(f"{item['name']} steht jetzt auf der Einkaufsliste.", "success") else: - flash(f"{item['name']} ist bereits auf der Einkaufsliste.", "info") + flash(f"Für „{item['name']}“ ist gerade nichts zusätzlich nötig.", "info") return redirect(request.referrer or url_for("main.shopping_list")) @@ -1011,11 +2090,7 @@ def item_set_home(item_id: int): return redirect(request.referrer or url_for("main.home_view")) get_db().execute( - """ - UPDATE items - SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, + "UPDATE items SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (g.user["id"], item_id), ) get_db().commit() @@ -1034,11 +2109,7 @@ def item_archive(item_id: int): return redirect(request.referrer or url_for("main.archive_view")) get_db().execute( - """ - UPDATE items - SET availability_state = 'archived', updated_by = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, + "UPDATE items SET availability_state = 'archived', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (g.user["id"], item_id), ) get_db().commit() @@ -1057,11 +2128,7 @@ def item_restore(item_id: int): return redirect(request.referrer or url_for("main.archive_view")) get_db().execute( - """ - UPDATE items - SET availability_state = 'idea', updated_by = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, + "UPDATE items SET availability_state = 'idea', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (g.user["id"], item_id), ) get_db().commit() @@ -1072,20 +2139,22 @@ def item_restore(item_id: int): @main_bp.route("/shopping", methods=("GET", "POST")) @login_required def shopping_list(): - database = get_db() - if request.method == "POST": selected_item_id = request.form.get("item_id", "").strip() - if not selected_item_id or not selected_item_id.isdigit(): + if not selected_item_id.isdigit(): flash("Bitte zuerst etwas auswählen.", "error") else: try: item = get_item(int(selected_item_id)) - added = add_to_shopping_list(item["id"], g.user["id"], visibility_override=item["visibility"]) - if added: - flash(f"{item['name']} wurde auf die Einkaufsliste gesetzt.", "success") + result = ensure_item_or_missing_components_are_shopped( + item["id"], + g.user["id"], + item["visibility"], + ) + if result["count"]: + flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}.", "success") else: - flash(f"{item['name']} ist bereits auf der Einkaufsliste.", "info") + flash("Dafür ist gerade nichts zusätzlich nötig.", "info") except ValueError as exc: flash(str(exc), "error") return redirect(url_for("main.shopping_list")) @@ -1122,24 +2191,15 @@ def shopping_check(entry_id: int): flash(str(exc), "error") return redirect(url_for("main.shopping_list")) - database = get_db() - database.execute( - """ - UPDATE shopping_entries - SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? - WHERE id = ? - """, + get_db().execute( + "UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?", (g.user["id"], entry_id), ) - database.execute( - """ - UPDATE items - SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, + get_db().execute( + "UPDATE items SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (g.user["id"], item["id"]), ) - database.commit() + get_db().commit() flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success") return redirect(url_for("main.shopping_list")) @@ -1161,14 +2221,11 @@ def shopping_remove(entry_id: int): if entry is None: flash("Der Eintrag wurde nicht gefunden.", "error") return redirect(url_for("main.shopping_list")) - - entry_dict = describe_record(dict(entry)) try: - ensure_can_edit(entry_dict, "Diesen Einkaufseintrag kannst du gerade nicht entfernen.") + ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht entfernen.") except PermissionError as exc: flash(str(exc), "error") return redirect(url_for("main.shopping_list")) - get_db().execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,)) get_db().commit() flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info") @@ -1189,10 +2246,9 @@ def home_view(): daypart_id=daypart_id, visibility=scope or None, ) - sections = build_home_sections(items, dayparts, daypart_id) return render_template( "home/list.html", - sections=sections, + sections=build_home_sections(items, dayparts, daypart_id), query=query, dayparts=dayparts, selected_daypart_id=daypart_id, @@ -1240,6 +2296,8 @@ def planner(): next_week=week_start + timedelta(days=7), week_cards=fetch_week_cards(week_start), today=date.today(), + week_templates=fetch_week_templates()[:6], + week_hints=build_week_hints(week_start), ) @@ -1254,61 +2312,62 @@ def planner_day(): note = request.form.get("note", "").strip() selected_date = parse_plan_date(request.form.get("plan_date")) visibility = normalize_visibility(request.form.get("visibility"), "shared") - - error = None - if not item_id_raw or not item_id_raw.isdigit(): - error = "Bitte etwas für den Tagesplan auswählen." - elif not daypart_id_raw or not daypart_id_raw.isdigit(): - error = "Bitte eine Tageszeit auswählen." - - if error is None: + if not item_id_raw.isdigit(): + flash("Bitte etwas für den Tagesplan auswählen.", "error") + elif not daypart_id_raw.isdigit(): + flash("Bitte eine Tageszeit auswählen.", "error") + else: item_id = int(item_id_raw) daypart_id = int(daypart_id_raw) try: - item = get_item(item_id) - except ValueError as exc: - error = str(exc) - - if error is None: - get_db().execute( - """ - INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, note, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - current_household_id(), + get_item(item_id) + get_db().execute( + """ + INSERT INTO plan_entries (household_id, owner_user_id, visibility, plan_date, daypart_id, item_id, note, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + visibility, + selected_date.isoformat(), + daypart_id, + item_id, + note, + g.user["id"], + ), + ) + get_db().commit() + shopping_result = ensure_item_or_missing_components_are_shopped( + item_id, g.user["id"], visibility, - selected_date.isoformat(), - daypart_id, - item_id, - note, - g.user["id"], - ), - ) - get_db().commit() - if ensure_planned_item_is_shopped(item_id, g.user["id"], visibility): - flash("Der Eintrag ist noch nicht zuhause und wurde zusätzlich auf die Einkaufsliste gesetzt.", "info") - flash("Der Eintrag wurde in den Tagesplan gelegt.", "success") - return redirect( - f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}" - ) - - flash(error, "error") + needed_for_date=selected_date.isoformat(), + needed_for_daypart_id=daypart_id, + ) + if shopping_result["count"]: + flash("Fehlende Dinge wurden zusätzlich auf die Einkaufsliste gesetzt.", "info") + flash("Der Eintrag wurde in den Tagesplan gelegt.", "success") + return redirect( + f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}" + ) + except ValueError as exc: + flash(str(exc), "error") selected_item_raw = request.args.get("item_id", "").strip() selected_daypart_raw = request.args.get("daypart_id", "").strip() selected_item_id = int(selected_item_raw) if selected_item_raw.isdigit() else None selected_daypart_id = int(selected_daypart_raw) if selected_daypart_raw.isdigit() else None - sections = build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id) return render_template( "planner/day.html", selected_date=selected_date, previous_day=selected_date - timedelta(days=1), next_day=selected_date + timedelta(days=1), - sections=sections, + sections=build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id), today=date.today(), visibility_options=VISIBILITY_FORM_OPTIONS, + day_templates=fetch_day_templates()[:6], + day_hints=build_day_hints(selected_date), ) @@ -1337,7 +2396,6 @@ def planner_remove(entry_id: int): flash("Der Planeintrag wurde entfernt.", "info") except PermissionError as exc: flash(str(exc), "error") - if selected_date: return redirect(url_for("main.planner_day", date=selected_date)) return redirect(url_for("main.planner")) @@ -1348,12 +2406,10 @@ def planner_remove(entry_id: int): def planner_move(entry_id: int): target_date = parse_plan_date(request.form.get("target_date")) target_daypart_raw = request.form.get("target_daypart_id", "").strip() - if not target_daypart_raw.isdigit(): return jsonify({"ok": False, "error": "Ungültige Tageszeit"}), 400 - database = get_db() - entry = database.execute( + entry = get_db().execute( f""" SELECT plan_entries.*, owner.display_name AS owner_display_name, @@ -1366,31 +2422,31 @@ def planner_move(entry_id: int): ).fetchone() if entry is None: return jsonify({"ok": False, "error": "Eintrag nicht gefunden"}), 404 - - entry_dict = describe_record(dict(entry)) try: - ensure_can_edit(entry_dict, "Diesen Planeintrag kannst du gerade nicht verschieben.") + ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht verschieben.") except PermissionError as exc: return jsonify({"ok": False, "error": str(exc)}), 403 target_daypart_id = int(target_daypart_raw) - database.execute( - """ - UPDATE plan_entries - SET plan_date = ?, daypart_id = ? - WHERE id = ? - """, + get_db().execute( + "UPDATE plan_entries SET plan_date = ?, daypart_id = ? WHERE id = ?", (target_date.isoformat(), target_daypart_id, entry_id), ) - database.commit() + get_db().commit() - was_added_to_shopping = ensure_planned_item_is_shopped(entry["item_id"], g.user["id"], entry["visibility"]) - if was_added_to_shopping: - flash("Der verschobene Eintrag ist noch nicht zuhause und wurde auf die Einkaufsliste gesetzt.", "info") + shopping_result = ensure_item_or_missing_components_are_shopped( + entry["item_id"], + g.user["id"], + entry["visibility"], + needed_for_date=target_date.isoformat(), + needed_for_daypart_id=target_daypart_id, + ) + if shopping_result["count"]: + flash("Fehlende Dinge wurden nach dem Verschieben auf die Einkaufsliste gesetzt.", "info") return jsonify( { "ok": True, - "added_to_shopping": was_added_to_shopping, + "added_to_shopping": bool(shopping_result["count"]), "redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()), } ) diff --git a/nouri/schema.sql b/nouri/schema.sql index 0792280..09d888f 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -24,6 +24,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique ON users (email) WHERE email IS NOT NULL AND email != ''; +CREATE TABLE IF NOT EXISTS household_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (household_id, name), + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS dayparts ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -35,6 +46,7 @@ CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, household_id INTEGER, owner_user_id INTEGER, + target_user_id INTEGER, visibility TEXT NOT NULL DEFAULT 'shared', kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')), name TEXT NOT NULL, @@ -48,6 +60,7 @@ CREATE TABLE IF NOT EXISTS items ( updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ); @@ -76,6 +89,8 @@ CREATE TABLE IF NOT EXISTS shopping_entries ( item_id INTEGER NOT NULL, added_by INTEGER, checked_by INTEGER, + needed_for_date TEXT, + needed_for_daypart_id INTEGER, is_checked INTEGER NOT NULL DEFAULT 0, added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, checked_at TEXT, @@ -83,7 +98,8 @@ CREATE TABLE IF NOT EXISTS shopping_entries ( FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL, - FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL + FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item @@ -108,12 +124,88 @@ CREATE TABLE IF NOT EXISTS plan_entries ( FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); +CREATE TABLE IF NOT EXISTS day_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', + name TEXT NOT NULL, + description TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS day_template_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_template_id INTEGER NOT NULL, + daypart_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE, + FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS week_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', + name TEXT NOT NULL, + description TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS week_template_days ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + week_template_id INTEGER NOT NULL, + weekday_index INTEGER NOT NULL, + day_template_id INTEGER NOT NULL, + UNIQUE (week_template_id, weekday_index), + FOREIGN KEY (week_template_id) REFERENCES week_templates(id) ON DELETE CASCADE, + FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS item_sets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', + name TEXT NOT NULL, + description TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS item_set_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_set_id INTEGER NOT NULL, + item_id INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 100, + UNIQUE (item_set_id, item_id), + FOREIGN KEY (item_set_id) REFERENCES item_sets(id) ON DELETE CASCADE, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE +); + CREATE INDEX IF NOT EXISTS idx_items_kind_name ON items (kind, name); CREATE INDEX IF NOT EXISTS idx_items_household_visibility ON items (household_id, visibility, availability_state); +CREATE INDEX IF NOT EXISTS idx_items_target_user +ON items (target_user_id); + CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item ON item_dayparts (daypart_id, item_id); @@ -125,3 +217,12 @@ ON plan_entries (household_id, visibility, plan_date); CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility ON shopping_entries (household_id, visibility, is_checked); + +CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility +ON day_templates (household_id, visibility, name); + +CREATE INDEX IF NOT EXISTS idx_week_templates_household_visibility +ON week_templates (household_id, visibility, name); + +CREATE INDEX IF NOT EXISTS idx_item_sets_household_visibility +ON item_sets (household_id, visibility, name); diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index f79a1d9..ba33131 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -67,6 +67,10 @@ body.has-mobile-nav { padding-bottom: 6rem; } +body.sheet-open { + overflow: hidden; +} + a { color: inherit; text-decoration: none; @@ -256,6 +260,11 @@ h3, display: none; } +.mobile-sheet-backdrop[hidden], +.mobile-more-sheet[hidden] { + display: none; +} + .content { display: grid; gap: 1.2rem; @@ -576,6 +585,12 @@ h3 { gap: 1rem; } +.template-library-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + .stack-form label, .planner-entry-form label, .filter-form label, @@ -640,6 +655,37 @@ legend { border: 1px solid var(--line); } +.check-option[hidden], +.quick-select-card[hidden] { + display: none; +} + +.quick-select-card { + display: flex; + align-items: flex-start; + gap: 0.7rem; + padding: 0.85rem 0.95rem; + min-width: 220px; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 82%, #fff 18%); +} + +.quick-select-card strong, +.template-list-card strong { + display: block; +} + +.quick-select-card small, +.template-list-card p, +.template-list-card small, +.hint-chip, +.suggestion-card p, +.suggestion-card small, +.week-template-row p { + color: var(--muted); +} + .inline-photo img { width: min(220px, 100%); border-radius: 18px; @@ -739,6 +785,10 @@ legend { margin-bottom: 1rem; } +.template-search-row { + margin-bottom: 0.9rem; +} + .quick-add-row form { margin: 0; } @@ -769,6 +819,49 @@ legend { background: color-mix(in srgb, var(--surface) 88%, #fff 12%); } +.template-card, +.template-list-card, +.suggestion-card { + padding: 0.95rem 1rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%); +} + +.template-list-card, +.week-template-row { + display: grid; + gap: 0.9rem; +} + +.week-template-row { + padding: 1rem; + border-radius: 18px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.34); +} + +.hint-list { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; +} + +.hint-chip { + margin: 0; + padding: 0.85rem 1rem; + border-radius: 16px; + background: color-mix(in srgb, var(--surface-strong) 80%, #fff 20%); + border: 1px solid var(--line); +} + +.suggestion-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.8rem; + margin-bottom: 1rem; +} + .planner-entry-top { align-items: flex-start; } @@ -915,6 +1008,71 @@ legend { mask-image: url("../icons/fa/sparkles.svg"); } +.icon-layer-group { + -webkit-mask-image: url("../icons/fa/layer-group.svg"); + mask-image: url("../icons/fa/layer-group.svg"); +} + +.icon-ellipsis { + -webkit-mask-image: url("../icons/fa/ellipsis.svg"); + mask-image: url("../icons/fa/ellipsis.svg"); +} + +.mobile-sheet-backdrop { + position: fixed; + inset: 0; + z-index: 24; + background: rgba(33, 29, 28, 0.22); + backdrop-filter: blur(6px); +} + +.mobile-more-sheet { + position: fixed; + left: 0.75rem; + right: 0.75rem; + bottom: 5.9rem; + z-index: 25; + padding: 1rem; + border-radius: 24px; + border: 1px solid var(--line); + background: var(--bg-elevated); + box-shadow: var(--shadow); + backdrop-filter: blur(24px) saturate(1.1); +} + +.mobile-sheet-head, +.mobile-sheet-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.8rem; +} + +.mobile-sheet-head small { + color: var(--muted); +} + +.mobile-sheet-links { + display: grid; + gap: 0.45rem; + margin: 1rem 0; +} + +.mobile-sheet-links a { + padding: 0.9rem 1rem; + border-radius: 16px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%); +} + +.mobile-sheet-actions { + flex-wrap: wrap; +} + +.mobile-sheet-actions > * { + flex: 1 1 180px; +} + @media (max-width: 1080px) { .site-header, .hero, @@ -932,6 +1090,7 @@ legend { .stats-grid, .two-column, + .template-library-grid, .inline-form, .planner-entry-form, .planner-entry-form-wide, @@ -1014,7 +1173,8 @@ legend { .mini-card-grid, .week-mini-grid, .week-overview-grid, - .more-link-grid { + .more-link-grid, + .template-library-grid { grid-template-columns: 1fr; } @@ -1079,7 +1239,20 @@ legend { font-size: 0.78rem; } - .mobile-bottom-nav a.active { + .mobile-nav-button { + display: grid; + justify-items: center; + gap: 0.28rem; + padding: 0.55rem 0.35rem; + border-radius: 16px; + border: 0; + background: transparent; + color: var(--muted); + font-size: 0.78rem; + } + + .mobile-bottom-nav a.active, + .mobile-nav-button.is-open { background: var(--accent-soft); color: var(--text); } @@ -1088,4 +1261,21 @@ legend { width: 1rem; height: 1rem; } + + .mobile-profile-link { + display: inline-flex; + padding: 0.35rem; + background: transparent; + } + + .mobile-profile-link .mobile-profile-avatar { + width: 2.15rem; + height: 2.15rem; + } + + .mobile-sheet-head, + .mobile-sheet-actions, + .week-template-row { + align-items: flex-start; + } } diff --git a/nouri/static/icons/fa/ellipsis.svg b/nouri/static/icons/fa/ellipsis.svg new file mode 100644 index 0000000..b05698b --- /dev/null +++ b/nouri/static/icons/fa/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/nouri/static/icons/fa/layer-group.svg b/nouri/static/icons/fa/layer-group.svg new file mode 100644 index 0000000..654361a --- /dev/null +++ b/nouri/static/icons/fa/layer-group.svg @@ -0,0 +1,3 @@ + + + diff --git a/nouri/static/js/ui.js b/nouri/static/js/ui.js new file mode 100644 index 0000000..c43afec --- /dev/null +++ b/nouri/static/js/ui.js @@ -0,0 +1,67 @@ +(() => { + const initMobileSheet = () => { + const sheet = document.querySelector("[data-mobile-sheet]"); + const backdrop = document.querySelector("[data-mobile-sheet-backdrop]"); + const openButtons = document.querySelectorAll("[data-mobile-sheet-open]"); + const closeButtons = document.querySelectorAll("[data-mobile-sheet-close]"); + if (!sheet || !backdrop || !openButtons.length) return; + + const closeSheet = () => { + sheet.hidden = true; + backdrop.hidden = true; + document.body.classList.remove("sheet-open"); + openButtons.forEach((button) => button.classList.remove("is-open")); + }; + + const openSheet = () => { + sheet.hidden = false; + backdrop.hidden = false; + document.body.classList.add("sheet-open"); + openButtons.forEach((button) => button.classList.add("is-open")); + }; + + openButtons.forEach((button) => { + button.addEventListener("click", openSheet); + }); + closeButtons.forEach((button) => { + button.addEventListener("click", closeSheet); + }); + backdrop.addEventListener("click", closeSheet); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeSheet(); + } + }); + + sheet.querySelectorAll("a").forEach((link) => { + link.addEventListener("click", closeSheet); + }); + }; + + const initFilterInputs = () => { + document.querySelectorAll("[data-filter-input]").forEach((input) => { + const listSelector = input.getAttribute("data-filter-target"); + if (!listSelector) return; + const container = document.querySelector(listSelector); + if (!container) return; + + const items = Array.from(container.querySelectorAll("[data-filter-label]")); + const applyFilter = () => { + const term = input.value.trim().toLowerCase(); + items.forEach((item) => { + const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase(); + item.hidden = Boolean(term) && !haystack.includes(term); + }); + }; + + input.addEventListener("input", applyFilter); + applyFilter(); + }); + }; + + document.addEventListener("DOMContentLoaded", () => { + initMobileSheet(); + initFilterInputs(); + }); +})(); diff --git a/nouri/templates/admin/categories.html b/nouri/templates/admin/categories.html new file mode 100644 index 0000000..f6cb5f4 --- /dev/null +++ b/nouri/templates/admin/categories.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Kategorien | Nouri{% endblock %} +{% block content %} +
+
+

Kategorien

+

Kategorien global anpassen

+

Hier pflegt ihr die Auswahl für Lebensmittel und Mahlzeiten. Bestehende Einträge bleiben auch dann erhalten, wenn eine Kategorie später pausiert wird.

+
+ Zur Nutzerverwaltung +
+ +
+
+ {{ csrf_input() }} + + +
+
+ +
+ {% for category in categories %} +
+
+ {{ category.name }} +

{% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %}

+
+ {% if category.is_active %} + Aktiv + {% else %} + Pausiert + {% endif %} +
+
+
+
+ {{ csrf_input() }} + +
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/nouri/templates/admin/users_list.html b/nouri/templates/admin/users_list.html index 383cd26..af8d228 100644 --- a/nouri/templates/admin/users_list.html +++ b/nouri/templates/admin/users_list.html @@ -5,9 +5,12 @@

Nutzer verwalten

Haushaltszugänge ruhig pflegen

-

Admins können hier weitere Mitglieder anlegen, Rollen anpassen und Zugänge bei Bedarf pausieren.

+

Admins können hier weitere Mitglieder anlegen, Rollen anpassen, Zugänge pausieren und die gemeinsamen Kategorien pflegen.

+
+ - Neuen Nutzer anlegen
diff --git a/nouri/templates/archive/list.html b/nouri/templates/archive/list.html index ed1a397..8f9ec94 100644 --- a/nouri/templates/archive/list.html +++ b/nouri/templates/archive/list.html @@ -54,6 +54,7 @@
{{ item.visibility_label }} {{ item.owner_label }} + {{ item.for_label }}

{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}

{% if item.dayparts %} diff --git a/nouri/templates/base.html b/nouri/templates/base.html index 9336c8d..f3d55f0 100644 --- a/nouri/templates/base.html +++ b/nouri/templates/base.html @@ -9,6 +9,7 @@ +
@@ -27,11 +28,12 @@ @@ -42,7 +44,7 @@ {{ role_labels[g.user.role] }} {% if g.user.role == 'admin' %} - Nutzer verwalten + Nutzer {% endif %}
{{ csrf_input() }} @@ -50,9 +52,9 @@
- - {{ (g.user.display_name or g.user.username)[0]|upper }} - + {% endif %} @@ -72,6 +74,36 @@ {% if g.user %} + + + {% endif %} diff --git a/nouri/templates/dashboard.html b/nouri/templates/dashboard.html index 537ddb9..89f009c 100644 --- a/nouri/templates/dashboard.html +++ b/nouri/templates/dashboard.html @@ -5,11 +5,11 @@

Heute

Ein ruhiger Blick auf euren Alltag

-

Du siehst schnell, was zuhause da ist, was schon geplant wurde und was gemeinsam oder persönlich vorbereitet ist.

+

Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo sanfte Unterstützung hilfreich sein kann.

@@ -31,6 +31,19 @@ +{% if dashboard_hints %} +
+
+

Sanfte Hinweise

+
+
+ {% for hint in dashboard_hints %} +

{{ hint }}

+ {% endfor %} +
+
+{% endif %} +
@@ -47,6 +60,7 @@
{{ entry.visibility_label }} {{ entry.owner_label }} + {{ entry.for_label }}
{% if entry.availability_state == 'home' %} @@ -72,7 +86,7 @@
{{ item.name }} {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} - {{ item.owner_label }} + {{ item.for_label }} {% if item.dayparts %}
{% for daypart in item.dayparts %} @@ -90,24 +104,51 @@
-
-
-

Nächste Tage

- Wochenansicht öffnen -
- +
+ + +
{% endblock %} diff --git a/nouri/templates/home/list.html b/nouri/templates/home/list.html index 1e194a5..327dfd0 100644 --- a/nouri/templates/home/list.html +++ b/nouri/templates/home/list.html @@ -62,6 +62,7 @@
{{ item.visibility_label }} {{ item.owner_label }} + {{ item.for_label }}

{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}

{% if item.components %} diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html index 8b814ef..ce0445b 100644 --- a/nouri/templates/items/form.html +++ b/nouri/templates/items/form.html @@ -5,12 +5,13 @@

{{ item_kind_labels[kind] }}

{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}

-

Nur das Nötigste: Name, Sichtbarkeit, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.

+

Nur das Nötigste: Name, Sichtbarkeit, für wen etwas gedacht ist, Bild, Tageszeiten und eine kleine Notiz.

{% if item %}
{{ item.visibility_label }} {{ item.owner_label }} + {{ item.for_label }}
{% endif %}
@@ -23,15 +24,26 @@ - +
+ + + +
@@ -77,20 +86,34 @@ {% if kind == 'meal' %}
Bestandteile der Mahlzeitenidee -

Optional: Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenklicken.

+

Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.

+
+ + +
{% if food_groups %} -
+
{% for group in food_groups %}

{{ group["title"] }}

{{ group["items"]|length }} Einträge
-
+
{% for food in group["items"] %} -
@@ -117,9 +140,6 @@ {% for category in categories %} {% endfor %} - {% if form_data.quick_food_category and form_data.quick_food_category not in categories %} - - {% endif %}
+
+
+
+

Tagesvorlagen

+ Als Vorlage speichern +
+ {% if day_templates %} +
+ {% for template in day_templates %} +
+ {{ csrf_input() }} + +
+ {{ template.name }} + {{ template.visibility_label }} · {{ template.owner_label }} +
+ +
+ {% endfor %} +
+ {% else %} +

Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.

+ {% endif %} +
+ + {% if day_hints %} +
+
+

Sanfte Hinweise

+
+
+ {% for hint in day_hints %} +

{{ hint }}

+ {% endfor %} +
+
+ {% endif %} +
+
{% for section in sections %}
@@ -33,6 +72,18 @@
+ {% if section.suggestions %} +
+ {% for suggestion in section.suggestions %} +
+ {{ suggestion.title }} + {{ suggestion.reason }} +

{{ suggestion.note }}

+
+ {% endfor %} +
+ {% endif %} + {% if section.quick_items %}
{% for item in section.quick_items %} @@ -44,7 +95,7 @@ {% endfor %} @@ -61,7 +112,7 @@ {% for item in section.candidates %} {% endfor %} @@ -92,6 +143,7 @@
{{ entry.visibility_label }} {{ entry.owner_label }} + {{ entry.for_label }}
{% if entry.can_edit %} diff --git a/nouri/templates/planner/week.html b/nouri/templates/planner/week.html index b3e5c68..cdb4202 100644 --- a/nouri/templates/planner/week.html +++ b/nouri/templates/planner/week.html @@ -5,7 +5,7 @@

Wochenansicht

Ein sanfter Blick auf die nächsten sieben Tage

-

Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben. Wenn etwas noch nicht zuhause ist, landet es dabei automatisch auf der Einkaufsliste.

+

Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und eine Woche bei Bedarf für später sichern.

Vorige Woche @@ -14,6 +14,45 @@
+
+
+
+

Wochenvorlagen

+ Als Vorlage speichern +
+ {% if week_templates %} +
+ {% for template in week_templates %} +
+ {{ csrf_input() }} + +
+ {{ template.name }} + {{ template.visibility_label }} · {{ template.owner_label }} +
+ +
+ {% endfor %} +
+ {% else %} +

Wenn eine Woche sich bewährt hat, kannst du sie hier später als Wochenvorlage wiederverwenden.

+ {% endif %} +
+ + {% if week_hints %} +
+
+

Sanfte Hinweise

+
+
+ {% for hint in week_hints %} +

{{ hint }}

+ {% endfor %} +
+
+ {% endif %} +
+
{% for card in week_cards %}
@@ -51,7 +90,7 @@ {% for entry in slot.entries %}
{{ entry.item_name }} - {{ entry.visibility_label }} · {{ entry.owner_label }} + {{ entry.visibility_label }} · {{ entry.for_label }}
{% endfor %}
diff --git a/nouri/templates/shopping/list.html b/nouri/templates/shopping/list.html index 2008ca2..7cd3872 100644 --- a/nouri/templates/shopping/list.html +++ b/nouri/templates/shopping/list.html @@ -5,7 +5,7 @@

Einkaufsliste

Was noch mitkommen soll

-

Abhaken legt Dinge automatisch unter Zuhause ab. Gemeinsame und persönliche Einträge bleiben dabei klar erkennbar.

+

Fehlende Lebensmittel aus einer Mahlzeit landen jetzt einzeln hier. So bleibt die Liste konkret und alltagsnah.

@@ -35,6 +35,13 @@
{{ entry.visibility_label }} {{ entry.owner_label }} + {{ entry.for_label }} + {% if entry.needed_for_label %} + + Für {{ entry.needed_for_label }} + {% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %} + + {% endif %}
@@ -55,7 +62,7 @@ {% else %}

Die Liste ist gerade frei

-

Einträge aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufügen.

+

Einträge aus Lebensmitteln, Vorlagen oder kleinen Paketen lassen sich jederzeit wieder hinzufügen.

{% endif %} {% endblock %}