From 96ab52e1ba6b78daa379eb1ea1bd73c24e51a54c Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Sun, 12 Apr 2026 16:39:04 +0200 Subject: [PATCH] release nouri 0.5.0 shopping rhythm pwa and reminders --- CloudronManifest.json | 4 +- Dockerfile.cloudron | 23 +- LICENSE.md | 52 + README.md | 20 +- RELEASE_NOTES_0.5.0.md | 47 + nouri/__init__.py | 25 + nouri/admin.py | 41 +- nouri/constants.py | 51 + nouri/db.py | 121 +- nouri/main.py | 1150 ++++++++++++++--- nouri/push.py | 50 + nouri/schema.sql | 65 + nouri/static/brand/pwa-192.png | Bin 0 -> 10148 bytes nouri/static/brand/pwa-512.png | Bin 0 -> 34400 bytes nouri/static/brand/pwa-badge.png | Bin 0 -> 4615 bytes nouri/static/css/styles.css | 183 ++- nouri/static/icons/fa/apple-whole.svg | 1 + nouri/static/icons/fa/bell.svg | 1 + nouri/static/icons/fa/heart.svg | 1 + nouri/static/icons/fa/leaf.svg | 1 + .../static/icons/fa/mobile-screen-button.svg | 1 + nouri/static/icons/fa/seedling.svg | 1 + nouri/static/icons/fa/sliders.svg | 1 + nouri/static/js/pwa.js | 96 ++ nouri/static/pwa/app.webmanifest | 23 + nouri/static/pwa/service-worker.js | 74 ++ nouri/templates/admin/categories.html | 26 +- nouri/templates/auth/profile.html | 7 + nouri/templates/base.html | 40 +- nouri/templates/dashboard.html | 51 +- nouri/templates/home/list.html | 20 + nouri/templates/library/index.html | 2 +- nouri/templates/planner/day.html | 124 +- nouri/templates/planner/week.html | 20 +- nouri/templates/settings.html | 125 ++ nouri/templates/shopping/list.html | 36 +- requirements.txt | 1 + 37 files changed, 2199 insertions(+), 285 deletions(-) create mode 100644 LICENSE.md create mode 100644 RELEASE_NOTES_0.5.0.md create mode 100644 nouri/push.py create mode 100644 nouri/static/brand/pwa-192.png create mode 100644 nouri/static/brand/pwa-512.png create mode 100644 nouri/static/brand/pwa-badge.png create mode 100644 nouri/static/icons/fa/apple-whole.svg create mode 100644 nouri/static/icons/fa/bell.svg create mode 100644 nouri/static/icons/fa/heart.svg create mode 100644 nouri/static/icons/fa/leaf.svg create mode 100644 nouri/static/icons/fa/mobile-screen-button.svg create mode 100644 nouri/static/icons/fa/seedling.svg create mode 100644 nouri/static/icons/fa/sliders.svg create mode 100644 nouri/static/js/pwa.js create mode 100644 nouri/static/pwa/app.webmanifest create mode 100644 nouri/static/pwa/service-worker.js create mode 100644 nouri/templates/settings.html diff --git a/CloudronManifest.json b/CloudronManifest.json index eef58bd..95e32e1 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.4.0", - "upstreamVersion": "0.4.0", + "version": "0.5.0", + "upstreamVersion": "0.5.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/Dockerfile.cloudron b/Dockerfile.cloudron index 3ad19d1..a83042e 100644 --- a/Dockerfile.cloudron +++ b/Dockerfile.cloudron @@ -2,19 +2,30 @@ FROM python:3.13-slim WORKDIR /app/code -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PORT=8000 -RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + sqlite3 \ && rm -rf /var/lib/apt/lists/* +RUN useradd -r -m -d /home/cloudron cloudron + COPY requirements.txt /app/code/ -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt gunicorn COPY . /app/code -RUN chmod +x /app/code/start.sh +# Lokale Daten für den ersten Start sichern und danach /app/code/data auf /app/data zeigen lassen +RUN if [ -d /app/code/data ]; then mv /app/code/data /app/bootstrap-data; else mkdir -p /app/bootstrap-data; fi \ + && ln -s /app/data /app/code/data \ + && chmod +x /app/code/start.sh \ + && chown -R cloudron:cloudron /app/code /app/bootstrap-data \ + && chown -h cloudron:cloudron /app/code/data + +USER cloudron EXPOSE 8000 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..2e6e4da --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,52 @@ +PolyForm Noncommercial License 1.0.0 + +Acceptance + +In order to get any license under these terms, you must agree to them as +both strict obligations and conditions to all your licenses. + +Copyright License + +The licensor grants you a copyright license for the software to do everything +you might do with the software that would otherwise infringe the licensor's +copyright in it for any permitted purpose. + +Limitations + +You may use this software for private, personal, internal, or other +noncommercial purposes only. + +Any use of this software or any derivative work for commercial advantage or +monetary compensation is not permitted under this license. + +Distribution of modified or unmodified copies must include this license text +and all copyright notices. + +Patent License + +The licensor grants you a patent license for the software that covers patent +claims the licensor can license, or becomes able to license, that you would +otherwise infringe by using the software for any permitted purpose. + +Notices + +You must ensure that anyone who gets a copy of any part of this software from +you also gets a copy of these terms. + +No Liability + +As far as the law allows, the software comes as is, without any warranty or +condition, and the licensor will not be liable to you for any damages arising +out of these terms or the use or nature of the software, under any kind of +legal claim. + +Definitions + +The licensor is the person or entity offering these terms, and the software is +the software the licensor makes available under these terms, including any +portion of it. + +Noncommercial means not primarily intended for or directed toward commercial +advantage or monetary compensation. + +If you need a standardized reference text, see https://polyformproject.org/licenses/noncommercial/1.0.0/ diff --git a/README.md b/README.md index eb3828b..fac8afb 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.4 +## Merkmale in Version 0.5 - Lebensmittel und Mahlzeitenideen anlegen - Fotos lokal hochladen @@ -19,10 +19,15 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein - 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 +- ruhige 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 +- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen +- ausgewogene Ergänzungsvorschläge auf Basis ruhiger Bausteine +- einfache Kombinations- und Rezeptideen aus zuhause vorhandenen Lebensmitteln +- Optionen für Erinnerungen, Hinweise und kleine Routinen +- PWA-Vorbereitung mit Web App Manifest, Service Worker und optionalem Web Push ## Lokal starten @@ -44,10 +49,13 @@ Wichtige Umgebungsvariablen: - `NOURI_SECRET_KEY`: Session-Secret für Produktion - `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` +- `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push +- `NOURI_VAPID_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push +- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io` -## Migration von 0.3 auf 0.4 +## Migration von 0.4 auf 0.5 -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. +Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Einkaufsrhythmus, vorgemerkte Bedarfe, Nutzer-Einstellungen, Push-Registrierungen und Baustein-Zuordnungen für Kategorien werden ergänzt. Vorhandene 0.4-Daten bleiben erhalten und werden weiterverwendet. ## Cloudron-Hinweis @@ -61,3 +69,7 @@ Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt: - `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten Wenn die App auf Cloudron bereits installiert ist, bitte **kein neues `cloudron install`** ausführen. Stattdessen die bestehende App aktualisieren, also ein neues Image bzw. Paket bauen und dann die vorhandene Installation updaten. + +## Lizenz + +Nouri ist in diesem Repository für private, nicht-kommerzielle Nutzung freigegeben. Details stehen in [LICENSE.md](LICENSE.md). diff --git a/RELEASE_NOTES_0.5.0.md b/RELEASE_NOTES_0.5.0.md new file mode 100644 index 0000000..7997f6f --- /dev/null +++ b/RELEASE_NOTES_0.5.0.md @@ -0,0 +1,47 @@ +# Nouri 0.5.0 + +## Highlights + +- Einkaufsrhythmus mit geplantem Einkaufstag und ruhiger Vorlauf-Logik +- Optionen für Erinnerungen, Hinweise und kleine Routinen +- Ausgewogene Ergänzungsvorschläge ohne Diät- oder Kontrollsprache +- Einfache Rezeptideen aus zuhause vorhandenen Lebensmitteln +- PWA-Vorbereitung mit Home-Screen-Nutzung, Service Worker und optionalem Web Push +- Überarbeitetes mobiles Mehr-Menü als Karten-Sheet mit Icons + +## Neu in 0.5.0 + +### Planung und Vorschläge + +- Der Tagesplan priorisiert jetzt vorhandene Mahlzeitenideen, dann passende Kombinationsvorschläge und danach einzelne Lebensmittel. +- Für Mittag- und Abendessen zeigt Nouri kleine Ergänzungsideen, wenn noch etwas gut dazupassen könnte. +- Zuhause vorhandene Lebensmittel werden zu einfachen Frühstücks- und Hauptmahlzeit-Ideen kombiniert. + +### Einkauf + +- Fehlende Lebensmittel aus zukünftigen Planungen landen nicht mehr sofort auf der Einkaufsliste. +- Stattdessen merkt Nouri sie zunächst für spätere Einkäufe vor und aktiviert sie passend zum eingestellten Einkaufstag. +- Auch bei Mahlzeiten werden nur die tatsächlich fehlenden Lebensmittel auf den Einkauf bezogen, nicht die Mahlzeit selbst. + +### Einstellungen + +- Neuer Bereich `Optionen` für Einkaufstag, Vorlauf, Hinweise, Routinen und Push. +- Hinweise lassen sich pro Person fein, aber weiterhin niedrigschwellig steuern. +- Push kann optional je Gerät aktiviert oder wieder beendet werden. + +### Mobile und PWA + +- Web App Manifest und Service Worker sind ergänzt. +- Nouri lässt sich besser auf dem iPhone zum Home-Bildschirm hinzufügen. +- Das mobile Mehr-Menü öffnet sich als kompaktes Karten-Sheet direkt über der Bottom Navigation. + +### Haushalt und Kategorien + +- Kategorien können jetzt zusätzlich einem ruhigen Baustein zugeordnet werden, zum Beispiel Proteinquelle, Gemüse oder Obst. +- Diese Zuordnung hilft nur intern bei Vorschlägen und fühlt sich nicht wie Tracking an. + +## Technische Hinweise + +- Neue SQLite-Tabellen und Spalten werden beim Start automatisch ergänzt. +- Für Web Push werden VAPID-Schlüssel über Umgebungsvariablen unterstützt. +- Cloudron-Version wurde auf `0.5.0` angehoben. diff --git a/nouri/__init__.py b/nouri/__init__.py index 0bf8ba1..9c436a4 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -11,13 +11,18 @@ from . import db from .admin import admin_bp from .auth import auth_bp from .constants import ( + BUILDER_DESCRIPTIONS, + BUILDER_LABELS, + BUILDER_OPTIONS, DAYPARTS, DEFAULT_CATEGORIES, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, + NOTIFICATION_CHANNEL_OPTIONS, ROLE_LABELS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, + WEEKDAY_OPTIONS, ) from .main import main_bp @@ -63,6 +68,10 @@ def create_app() -> Flask: PERMANENT_SESSION_LIFETIME=timedelta(days=30), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", + APP_VERSION="0.5.0", + VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), + VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), + VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), ) db.init_app(app) @@ -78,11 +87,19 @@ def create_app() -> Flask: "item_kind_labels": ITEM_KIND_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, "category_suggestions": DEFAULT_CATEGORIES, + "builder_labels": BUILDER_LABELS, + "builder_descriptions": BUILDER_DESCRIPTIONS, + "builder_options": BUILDER_OPTIONS, "daypart_suggestions": DAYPARTS, "visibility_labels": VISIBILITY_LABELS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS, "role_labels": ROLE_LABELS, + "weekday_options": WEEKDAY_OPTIONS, + "notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS, "today": date.today(), + "app_version": app.config["APP_VERSION"], + "push_public_key": app.config["VAPID_PUBLIC_KEY"], + "push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]), "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], "is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin", @@ -92,4 +109,12 @@ def create_app() -> Flask: def uploaded_file(filename: str): return send_from_directory(app.config["UPLOAD_FOLDER"], filename) + @app.get("/app.webmanifest") + def webmanifest(): + return send_from_directory(root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json") + + @app.get("/service-worker.js") + def service_worker(): + return send_from_directory(root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript") + return app diff --git a/nouri/admin.py b/nouri/admin.py index 7f519eb..dbf442c 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 DEFAULT_CATEGORIES, ROLE_LABELS +from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS from .db import get_db @@ -197,6 +197,7 @@ def user_edit(user_id: int): def category_settings(): if request.method == "POST": name = request.form.get("name", "").strip() + builder_key = request.form.get("builder_key", "neutral").strip() if not name: flash("Bitte einen Kategorienamen eintragen.", "error") else: @@ -210,8 +211,8 @@ def category_settings(): ).fetchone() if existing: get_db().execute( - "UPDATE household_categories SET is_active = 1 WHERE id = ?", - (existing["id"],), + "UPDATE household_categories SET is_active = 1, builder_key = ? WHERE id = ?", + (builder_key, existing["id"]), ) flash("Die Kategorie ist wieder aktiv.", "success") else: @@ -221,10 +222,10 @@ def category_settings(): ).fetchone() get_db().execute( """ - INSERT INTO household_categories (household_id, name, sort_order, is_active) - VALUES (?, ?, ?, 1) + INSERT INTO household_categories (household_id, name, builder_key, sort_order, is_active) + VALUES (?, ?, ?, ?, 1) """, - (g.user["household_id"], name, int(sort_row["max_sort"]) + 10), + (g.user["household_id"], name, builder_key, int(sort_row["max_sort"]) + 10), ) flash("Die Kategorie wurde ergänzt.", "success") get_db().commit() @@ -234,6 +235,9 @@ def category_settings(): "admin/categories.html", categories=fetch_household_categories(), default_categories=DEFAULT_CATEGORIES, + default_category_builders=DEFAULT_CATEGORY_BUILDERS, + builder_options=BUILDER_OPTIONS, + builder_descriptions=BUILDER_DESCRIPTIONS, ) @@ -260,3 +264,28 @@ def category_toggle(category_id: int): get_db().commit() flash("Die Kategorie wurde aktualisiert.", "success") return redirect(url_for("admin.category_settings")) + + +@admin_bp.post("/categories//update") +@admin_required +def category_update(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")) + + builder_key = request.form.get("builder_key", "neutral").strip() + get_db().execute( + "UPDATE household_categories SET builder_key = ? WHERE id = ?", + (builder_key, category_id), + ) + get_db().commit() + flash("Die Zuordnung wurde aktualisiert.", "success") + return redirect(url_for("admin.category_settings")) diff --git a/nouri/constants.py b/nouri/constants.py index e038155..6779f28 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -20,6 +20,57 @@ DEFAULT_CATEGORIES = [ "Kleines Essen", ] +DEFAULT_CATEGORY_BUILDERS = { + "Brot & Getreide": "carb", + "Milchprodukt": "dairy", + "Obst": "fruit", + "Gemüse": "veg", + "Eiweißquelle": "protein", + "Snack": "neutral", + "Getränk": "neutral", + "Vorrat & Basics": "neutral", + "Warmes": "carb", + "Kleines Essen": "neutral", +} + +BUILDER_LABELS = { + "protein": "Proteinquelle", + "carb": "Kohlenhydratquelle", + "veg": "Gemüse / Ballaststoffquelle", + "nuts": "Nüsse / Samen", + "fruit": "Obst", + "dairy": "Milchprodukt", + "neutral": "Neutral / sonstiges", +} + +BUILDER_DESCRIPTIONS = { + "protein": "Passt eher zu sättigenden Eiweißquellen.", + "carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.", + "veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.", + "nuts": "Passt eher zu Nüssen oder Samen.", + "fruit": "Passt eher zu Obst.", + "dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.", + "neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.", +} + +BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()] + +WEEKDAY_OPTIONS = [ + (0, "Montag"), + (1, "Dienstag"), + (2, "Mittwoch"), + (3, "Donnerstag"), + (4, "Freitag"), + (5, "Samstag"), + (6, "Sonntag"), +] + +NOTIFICATION_CHANNEL_OPTIONS = [ + ("in_app", "Nur in der App"), + ("push", "Nur Push"), + ("both", "App und Push"), +] + ITEM_KIND_LABELS = { "food": "Lebensmittel", "meal": "Mahlzeitenideen", diff --git a/nouri/db.py b/nouri/db.py index f198733..e7602e7 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, DEFAULT_CATEGORIES +from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS def get_db() -> sqlite3.Connection: @@ -53,6 +53,9 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: CREATE TABLE IF NOT EXISTS households ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, + shopping_weekday INTEGER NOT NULL DEFAULT 5, + shopping_prep_days INTEGER NOT NULL DEFAULT 1, + shopping_reminder_time TEXT NOT NULL DEFAULT '18:00', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) """ @@ -64,6 +67,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: id INTEGER PRIMARY KEY AUTOINCREMENT, household_id INTEGER NOT NULL, name TEXT NOT NULL, + builder_key TEXT NOT NULL DEFAULT 'neutral', sort_order INTEGER NOT NULL DEFAULT 100, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -72,6 +76,14 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: """ ) + if table_exists(database, "households"): + add_column_if_missing(database, "households", "shopping_weekday INTEGER NOT NULL DEFAULT 5") + add_column_if_missing(database, "households", "shopping_prep_days INTEGER NOT NULL DEFAULT 1") + add_column_if_missing(database, "households", "shopping_reminder_time TEXT NOT NULL DEFAULT '18:00'") + + if table_exists(database, "household_categories"): + add_column_if_missing(database, "household_categories", "builder_key TEXT NOT NULL DEFAULT 'neutral'") + if table_exists(database, "users"): add_column_if_missing(database, "users", "household_id INTEGER") add_column_if_missing(database, "users", "email TEXT") @@ -92,6 +104,82 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") + if table_exists(database, "shopping_needs"): + add_column_if_missing(database, "shopping_needs", "source_item_id INTEGER") + add_column_if_missing(database, "shopping_needs", "activation_date TEXT") + add_column_if_missing(database, "shopping_needs", "is_activated INTEGER NOT NULL DEFAULT 0") + add_column_if_missing(database, "shopping_needs", "activated_at TEXT") + + database.execute( + """ + CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY, + reminders_enabled INTEGER NOT NULL DEFAULT 1, + push_enabled INTEGER NOT NULL DEFAULT 0, + notification_channel TEXT NOT NULL DEFAULT 'in_app', + remind_before_shopping INTEGER NOT NULL DEFAULT 1, + remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, + show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1, + show_planned_not_shopped INTEGER NOT NULL DEFAULT 1, + remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1, + remind_week_if_sparse INTEGER NOT NULL DEFAULT 1, + suggest_home_for_today INTEGER NOT NULL DEFAULT 1, + remind_small_snack INTEGER NOT NULL DEFAULT 0, + remind_nuts INTEGER NOT NULL DEFAULT 0, + show_meal_balancing INTEGER NOT NULL DEFAULT 1, + suggest_templates INTEGER NOT NULL DEFAULT 1, + suggest_patterns INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + database.execute( + """ + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_test_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + database.execute( + """ + CREATE TABLE IF NOT EXISTS shopping_needs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', + item_id INTEGER NOT NULL, + source_item_id INTEGER, + needed_for_date TEXT NOT NULL, + needed_for_daypart_id INTEGER, + activation_date TEXT NOT NULL, + is_activated INTEGER NOT NULL DEFAULT 0, + activated_at TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility), + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (source_item_id) REFERENCES items(id) ON DELETE SET NULL, + FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + 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") @@ -126,10 +214,18 @@ def sync_default_categories(database: sqlite3.Connection) -> None: 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) + INSERT OR IGNORE INTO household_categories (household_id, name, builder_key, sort_order, is_active) + VALUES (?, ?, ?, ?, 1) """, - (household_id, name, sort_order), + (household_id, name, DEFAULT_CATEGORY_BUILDERS.get(name, "neutral"), sort_order), + ) + database.execute( + """ + UPDATE household_categories + SET builder_key = COALESCE(NULLIF(builder_key, ''), ?) + WHERE household_id = ? AND name = ? + """, + (DEFAULT_CATEGORY_BUILDERS.get(name, "neutral"), household_id, name), ) @@ -141,6 +237,11 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "users", "updated_at TEXT") default_household_id = ensure_default_household(database) + database.execute("UPDATE households SET shopping_weekday = COALESCE(shopping_weekday, 5)") + database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)") + database.execute( + "UPDATE households SET shopping_reminder_time = COALESCE(NULLIF(shopping_reminder_time, ''), '18:00')" + ) database.execute( "UPDATE users SET household_id = ? WHERE household_id IS NULL", (default_household_id,), @@ -204,6 +305,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''") sync_default_categories(database) + database.execute( + """ + INSERT OR IGNORE INTO user_settings (user_id) + SELECT id FROM users + """ + ) database.execute( """ @@ -236,6 +343,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON shopping_entries (household_id, visibility, is_checked) """ ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation + ON shopping_needs (household_id, activation_date, is_activated) + """ + ) def apply_schema(database: sqlite3.Connection) -> None: diff --git a/nouri/main.py b/nouri/main.py index 50d3262..c27fd45 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -21,16 +21,21 @@ from werkzeug.utils import secure_filename from .auth import login_required from .constants import ( AVAILABILITY_LABELS, + BUILDER_LABELS, + DEFAULT_CATEGORY_BUILDERS, DAY_TEMPLATE_NAME_SUGGESTIONS, DEFAULT_CATEGORIES, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, ITEM_SET_NAME_SUGGESTIONS, + NOTIFICATION_CHANNEL_OPTIONS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, + WEEKDAY_OPTIONS, WEEK_TEMPLATE_NAME_SUGGESTIONS, ) from .db import get_db +from .push import push_is_configured, push_public_key, send_push_message main_bp = Blueprint("main", __name__) @@ -59,6 +64,17 @@ TARGET_USER_OPTIONS_DEFAULT = "__all__" WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] +@main_bp.before_app_request +def refresh_due_context(): + endpoint = request.endpoint or "" + if getattr(g, "user", None) is None: + return None + if request.method == "GET" and endpoint.startswith("main."): + ensure_user_settings_row() + activate_due_shopping_needs() + return None + + def current_household_id() -> int: return int(g.user["household_id"]) @@ -110,6 +126,95 @@ def get_category_options(include_inactive_selected: str | None = None) -> list[s return categories +def get_category_builder_map() -> dict[str, str]: + rows = get_db().execute( + """ + SELECT name, builder_key + FROM household_categories + WHERE household_id = ? + """, + (current_household_id(),), + ).fetchall() + builder_map = {row["name"]: (row["builder_key"] or "neutral") for row in rows} + for name, builder_key in DEFAULT_CATEGORY_BUILDERS.items(): + builder_map.setdefault(name, builder_key) + return builder_map + + +def get_household_settings() -> dict: + row = get_db().execute( + """ + SELECT shopping_weekday, shopping_prep_days, shopping_reminder_time + FROM households + WHERE id = ? + """, + (current_household_id(),), + ).fetchone() + if row is None: + return { + "shopping_weekday": 5, + "shopping_prep_days": 1, + "shopping_reminder_time": "18:00", + } + return { + "shopping_weekday": int(row["shopping_weekday"] or 5), + "shopping_prep_days": int(row["shopping_prep_days"] or 1), + "shopping_reminder_time": row["shopping_reminder_time"] or "18:00", + } + + +def ensure_user_settings_row() -> None: + get_db().execute( + "INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", + (g.user["id"],), + ) + + +def get_user_settings() -> dict: + ensure_user_settings_row() + row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone() + if row is None: + return {} + settings = dict(row) + boolean_fields = { + "reminders_enabled", + "push_enabled", + "remind_before_shopping", + "remind_on_shopping_day", + "show_missing_for_upcoming_week", + "show_planned_not_shopped", + "remind_tomorrow_if_sparse", + "remind_week_if_sparse", + "suggest_home_for_today", + "remind_small_snack", + "remind_nuts", + "show_meal_balancing", + "suggest_templates", + "suggest_patterns", + } + for field in boolean_fields: + settings[field] = bool(settings.get(field)) + settings["notification_channel"] = settings.get("notification_channel") or "in_app" + return settings + + +def parse_checkbox(name: str, default: bool = False) -> int: + return 1 if request.form.get(name, "1" if default else "0") == "1" else 0 + + +def normalize_weekday(raw: str | None, default: int = 5) -> int: + if raw and raw.isdigit(): + value = int(raw) + if 0 <= value <= 6: + return value + return default + + +def normalize_notification_channel(raw: str | None, default: str = "in_app") -> str: + allowed = {value for value, _label in NOTIFICATION_CHANNEL_OPTIONS} + return raw if raw in allowed else default + + def visible_clause(table_alias: str) -> str: return ( f"{table_alias}.household_id = ? " @@ -331,6 +436,81 @@ def attach_components(items: list[dict]) -> list[dict]: return items +def attach_builder_keys(items: list[dict]) -> list[dict]: + if not items: + return [] + + category_builder_map = get_category_builder_map() + meal_ids = [item["id"] for item in items if item["kind"] == "meal"] + meal_builder_map: dict[int, set[str]] = defaultdict(set) + + if meal_ids: + placeholders = ",".join("?" for _ in meal_ids) + rows = get_db().execute( + f""" + SELECT meal_components.meal_item_id, + component.category + FROM meal_components + JOIN items AS component ON component.id = meal_components.food_item_id + WHERE meal_components.meal_item_id IN ({placeholders}) + """, + meal_ids, + ).fetchall() + for row in rows: + builder_key = category_builder_map.get(row["category"] or "", "neutral") + meal_builder_map[int(row["meal_item_id"])].add(builder_key) + + for item in items: + builder_keys: list[str] + if item["kind"] == "meal": + builder_keys = sorted(meal_builder_map.get(item["id"], set())) + if not builder_keys: + builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")] + else: + builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")] + item["builder_keys"] = builder_keys + item["builder_labels"] = [BUILDER_LABELS.get(key, BUILDER_LABELS["neutral"]) for key in builder_keys] + item["primary_builder_key"] = builder_keys[0] if builder_keys else "neutral" + return items + + +def decorate_items(rows) -> list[dict]: + return attach_builder_keys(attach_components(attach_dayparts(describe_records(rows)))) + + +def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]: + if not item_ids: + return {} + category_builder_map = get_category_builder_map() + placeholders = ",".join("?" for _ in item_ids) + rows = get_db().execute( + f""" + SELECT id, kind, category + FROM items + WHERE id IN ({placeholders}) + """, + item_ids, + ).fetchall() + builder_map: dict[int, set[str]] = {int(row["id"]): {category_builder_map.get(row["category"] or "", "neutral")} for row in rows} + meal_ids = [int(row["id"]) for row in rows if row["kind"] == "meal"] + if meal_ids: + meal_placeholders = ",".join("?" for _ in meal_ids) + component_rows = get_db().execute( + f""" + SELECT meal_components.meal_item_id, component.category + FROM meal_components + JOIN items AS component ON component.id = meal_components.food_item_id + WHERE meal_components.meal_item_id IN ({meal_placeholders}) + """, + meal_ids, + ).fetchall() + for row in component_rows: + builder_map.setdefault(int(row["meal_item_id"]), set()).add( + category_builder_map.get(row["category"] or "", "neutral") + ) + return builder_map + + def fetch_items( *, kind: str | None = None, @@ -393,7 +573,7 @@ def fetch_items( """, params, ).fetchall() - return attach_components(attach_dayparts(describe_records(rows))) + return decorate_items(rows) def fetch_food_options(query: str | None = None): @@ -460,6 +640,93 @@ def create_quick_food_from_form(form_data: dict) -> int: return food_id +def shopping_activation_date_for(needed_date: date) -> date: + settings = get_household_settings() + shopping_weekday = int(settings["shopping_weekday"]) + prep_days = int(settings["shopping_prep_days"]) + days_back = (needed_date.weekday() - shopping_weekday) % 7 + shopping_date = needed_date - timedelta(days=days_back) + return shopping_date - timedelta(days=prep_days) + + +def should_activate_shopping_need(needed_date: date, today: date | None = None) -> bool: + return (today or date.today()) >= shopping_activation_date_for(needed_date) + + +def schedule_shopping_need( + *, + item_id: int, + user_id: int, + visibility: str, + needed_for_date: str, + needed_for_daypart_id: int | None, + source_item_id: int | None = None, +) -> bool: + activation_date = shopping_activation_date_for(parse_plan_date(needed_for_date)) + existing = get_db().execute( + """ + SELECT id + FROM shopping_needs + WHERE household_id = ? + AND item_id = ? + AND COALESCE(source_item_id, 0) = COALESCE(?, 0) + AND needed_for_date = ? + AND COALESCE(needed_for_daypart_id, 0) = COALESCE(?, 0) + AND visibility = ? + AND is_activated = 0 + LIMIT 1 + """, + ( + current_household_id(), + item_id, + source_item_id, + needed_for_date, + needed_for_daypart_id, + visibility, + ), + ).fetchone() + if existing: + get_db().execute( + """ + UPDATE shopping_needs + SET activation_date = ?, + owner_user_id = ? + WHERE id = ? + """, + (activation_date.isoformat(), user_id, existing["id"]), + ) + else: + get_db().execute( + """ + INSERT INTO shopping_needs ( + household_id, + owner_user_id, + visibility, + item_id, + source_item_id, + needed_for_date, + needed_for_daypart_id, + activation_date, + created_by + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + current_household_id(), + user_id, + visibility, + item_id, + source_item_id, + needed_for_date, + needed_for_daypart_id, + activation_date.isoformat(), + g.user["id"], + ), + ) + get_db().commit() + return True + + def add_to_shopping_list( item_id: int, user_id: int, @@ -523,7 +790,7 @@ def fetch_meal_missing_components(meal_id: int) -> list[dict]: """, (meal_id, current_household_id(), g.user["id"]), ).fetchall() - return attach_dayparts(describe_records(rows)) + return attach_builder_keys(attach_dayparts(describe_records(rows))) def ensure_item_or_missing_components_are_shopped( @@ -533,32 +800,79 @@ def ensure_item_or_missing_components_are_shopped( *, needed_for_date: str | None = None, needed_for_daypart_id: int | None = None, + source_item_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 = [] + scheduled_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"]) + if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)): + schedule_shopping_need( + item_id=component["id"], + user_id=user_id, + visibility=visibility, + needed_for_date=needed_for_date, + needed_for_daypart_id=needed_for_daypart_id, + source_item_id=source_item_id or item_id, + ) + scheduled_names.append(component["name"]) + else: + 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, + "scheduled_count": len(scheduled_names), + "scheduled_names": scheduled_names, "used_components": True, } - return {"added": False, "count": 0, "names": [], "used_components": True} + return { + "added": False, + "count": 0, + "names": [], + "scheduled_count": 0, + "scheduled_names": [], + "used_components": True, + } if item["availability_state"] == "home": - return {"added": False, "count": 0, "names": [], "used_components": False} + return { + "added": False, + "count": 0, + "names": [], + "scheduled_count": 0, + "scheduled_names": [], + "used_components": False, + } + + if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)): + schedule_shopping_need( + item_id=item_id, + user_id=user_id, + visibility=visibility, + needed_for_date=needed_for_date, + needed_for_daypart_id=needed_for_daypart_id, + source_item_id=source_item_id, + ) + return { + "added": False, + "count": 0, + "names": [], + "scheduled_count": 1, + "scheduled_names": [item["name"]], + "used_components": False, + } added = add_to_shopping_list( item_id, @@ -571,6 +885,8 @@ def ensure_item_or_missing_components_are_shopped( "added": added, "count": 1 if added else 0, "names": [item["name"]] if added else [], + "scheduled_count": 0, + "scheduled_names": [], "used_components": False, } @@ -643,6 +959,80 @@ def fetch_shopping_entries(): return entries +def activate_due_shopping_needs(today: date | None = None) -> int: + today = today or date.today() + rows = get_db().execute( + """ + SELECT shopping_needs.*, + items.availability_state + FROM shopping_needs + JOIN items ON items.id = shopping_needs.item_id + WHERE shopping_needs.household_id = ? + AND shopping_needs.is_activated = 0 + AND shopping_needs.activation_date <= ? + ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date + """, + (current_household_id(), today.isoformat()), + ).fetchall() + activated = 0 + for row in rows: + if row["availability_state"] != "home": + was_added = add_to_shopping_list( + int(row["item_id"]), + int(row["owner_user_id"] or g.user["id"]), + visibility_override=row["visibility"], + needed_for_date=row["needed_for_date"], + needed_for_daypart_id=row["needed_for_daypart_id"], + ) + activated += 1 if was_added else 0 + get_db().execute( + "UPDATE shopping_needs SET is_activated = 1, activated_at = CURRENT_TIMESTAMP WHERE id = ?", + (row["id"],), + ) + if rows: + get_db().commit() + return activated + + +def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]: + query = f""" + SELECT shopping_needs.*, + items.name AS item_name, + items.kind AS item_kind, + items.photo_filename, + items.availability_state, + owner.display_name AS owner_display_name, + owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username, + dayparts.name AS needed_daypart_name + FROM shopping_needs + JOIN items ON items.id = shopping_needs.item_id + LEFT JOIN users AS owner ON owner.id = shopping_needs.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id + LEFT JOIN dayparts ON dayparts.id = shopping_needs.needed_for_daypart_id + WHERE shopping_needs.household_id = ? + AND shopping_needs.is_activated = 0 + AND (shopping_needs.visibility = 'shared' OR shopping_needs.owner_user_id = ?) + AND items.availability_state != 'home' + ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date, LOWER(items.name) + """ + params: list[object] = [current_household_id(), g.user["id"]] + if limit: + query += " LIMIT ?" + params.append(limit) + rows = get_db().execute(query, params).fetchall() + entries = describe_records(rows) + for entry in entries: + try: + entry["needed_for_label"] = datetime.strptime(entry["needed_for_date"], "%Y-%m-%d").date().strftime("%d.%m.%Y") + entry["activation_label"] = datetime.strptime(entry["activation_date"], "%Y-%m-%d").date().strftime("%d.%m.%Y") + except ValueError: + entry["needed_for_label"] = entry["needed_for_date"] + entry["activation_label"] = entry["activation_date"] + return entries + + def fetch_plan_entries_for_range(start_date: date, end_date: date): rows = get_db().execute( f""" @@ -706,7 +1096,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6): """, [daypart_id, *visible_params(), limit * 3], ).fetchall() - return attach_components(attach_dayparts(describe_records(rows))) + return decorate_items(rows) def fetch_plan_candidates(daypart_id: int, query: str | None = None): @@ -740,7 +1130,7 @@ def fetch_plan_candidates(daypart_id: int, query: str | None = None): """, params, ).fetchall() - return attach_components(attach_dayparts(describe_records(rows))) + return decorate_items(rows) def fetch_home_food_ids() -> set[int]: @@ -755,179 +1145,271 @@ def fetch_home_food_ids() -> set[int]: 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] = [] +def get_daypart_by_id(daypart_id: int): + for daypart in get_dayparts(): + if int(daypart["id"]) == int(daypart_id): + return daypart + return None + +def format_item_names(items: list[dict], limit: int = 3) -> str: + return ", ".join(item["name"] for item in items[:limit]) + + +def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: + home_foods = fetch_items(kind="food", availability="home", daypart_id=daypart_id) + home_food_ids = {item["id"] for item in home_foods} + builder_groups: dict[str, list[dict]] = defaultdict(list) + for food in home_foods: + for builder_key in food.get("builder_keys", ["neutral"]): + builder_groups[builder_key].append(food) + + 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"]): + if meal["component_ids"] and 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.", + "component_ids": meal["component_ids"], + "existing_item_id": meal["id"], } ) - 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.", - } - ) + daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "") + if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: + if builder_groups["carb"] and builder_groups["dairy"]: + combo = [builder_groups["carb"][0], builder_groups["dairy"][0]] + if builder_groups["fruit"]: + combo.append(builder_groups["fruit"][0]) + if builder_groups["nuts"]: + combo.append(builder_groups["nuts"][0]) + suggestions.append( + { + "title": " mit ".join([combo[0]["name"], combo[1]["name"]]) if len(combo) == 2 else f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}", + "reason": "Lässt sich gut ergänzen", + "component_ids": [item["id"] for item in combo], + "existing_item_id": None, + } + ) + else: + if builder_groups["protein"] and builder_groups["carb"]: + combo = [builder_groups["protein"][0], builder_groups["carb"][0]] + if builder_groups["veg"]: + combo.append(builder_groups["veg"][0]) + suggestions.append( + { + "title": f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}", + "reason": "Aus Zuhause zusammengesetzt", + "component_ids": [item["id"] for item in combo], + "existing_item_id": None, + } + ) - 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 = [] + deduped: list[dict] = [] seen = set() for suggestion in suggestions: - key = suggestion["title"] - if key in seen: + if suggestion["title"] in seen: continue - seen.add(key) + seen.add(suggestion["title"]) deduped.append(suggestion) - if len(deduped) >= 4: + if len(deduped) >= limit: break return deduped +def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | None: + settings = get_user_settings() + if not (settings.get("reminders_enabled") and settings.get("show_meal_balancing")): + return None + daypart = get_daypart_by_id(daypart_id) + if not daypart or daypart["slug"] not in {"lunch", "dinner"}: + return None + builder_map = fetch_builder_keys_for_item_ids(item_ids) + present = set() + for keys in builder_map.values(): + present.update(keys) + target_order = ["protein", "carb", "veg"] + missing = [key for key in target_order if key not in present] + if not missing: + return None + first_missing = missing[0] + home_matches = [ + item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id) + if first_missing in item.get("builder_keys", []) + ] + text_map = { + "protein": "Dazu könnte noch eine Proteinquelle gut passen.", + "carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.", + "veg": "Dazu könnte noch etwas Gemüse gut passen.", + } + return { + "text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."), + "items": home_matches[:3], + } + + +def build_daypart_suggestions(daypart_id: int) -> list[dict]: + settings = get_user_settings() + if not settings.get("suggest_home_for_today"): + return [] + suggestions = build_home_recipe_suggestions(daypart_id, limit=3) + if suggestions: + return suggestions + + archived_items = fetch_items(availability="archived", include_archived=True, daypart_id=daypart_id) + return [ + { + "title": item["name"], + "reason": "Für später vormerken", + "component_ids": [], + "existing_item_id": item["id"] if item["kind"] == "meal" else None, + } + for item in archived_items[:2] + ] + + def build_dashboard_hints(today: date) -> list[str]: + settings = get_user_settings() + if not settings.get("reminders_enabled"): + return [] + 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.") + household_settings = get_household_settings() - 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.") + if settings.get("remind_tomorrow_if_sparse"): + 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.") - 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] + if settings.get("suggest_home_for_today"): + 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 da, das gut zu Abendessen passt.") + + if settings.get("remind_before_shopping") and (today + timedelta(days=1)).weekday() == household_settings["shopping_weekday"]: + upcoming = fetch_upcoming_shopping_needs(limit=3) + if upcoming: + hints.append("Für den nächsten Einkauf sind schon ein paar Dinge vorgemerkt.") + + if settings.get("remind_nuts"): + nut_items = [item for item in fetch_items(kind="food", availability="home") if "nuts" in item.get("builder_keys", [])] + if nut_items: + hints.append("Heute schon an Nüsse gedacht?") + + if settings.get("suggest_templates"): + 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"„{old_template['name']}“ könnte diese Woche wieder passen.") + return hints[:4] 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.") + settings = get_user_settings() + if not settings.get("reminders_enabled"): + return [] - 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] + hints: list[str] = [] + if settings.get("remind_tomorrow_if_sparse"): + 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.") + + if settings.get("remind_small_snack"): + 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 wäre noch etwas Kleines möglich.") + + if settings.get("show_planned_not_shopped"): + pending_for_day = fetch_upcoming_shopping_needs(limit=20) + pending_for_day = [entry for entry in pending_for_day if entry["needed_for_date"] == selected_date.isoformat()] + if pending_for_day: + hints.append("Ein paar Dinge sind für diesen Tag schon vorgemerkt, aber noch nicht auf der Einkaufsliste.") + return hints[:4] def build_week_hints(week_start: date) -> list[str]: + settings = get_user_settings() + if not settings.get("reminders_enabled"): + return [] + 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.") + if settings.get("remind_week_if_sparse"): + 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"Für diese Woche sind noch {missing_breakfasts} Tage ohne Frühstück eingeplant.") - 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] + if settings.get("show_missing_for_upcoming_week"): + due_entries = [entry for entry in fetch_upcoming_shopping_needs(limit=20) if week_start.isoformat() <= entry["needed_for_date"] <= week_end.isoformat()] + if due_entries: + hints.append("Für diese Woche fehlt noch etwas, das später zum Einkauf dazukommen kann.") + + if settings.get("remind_on_shopping_day") and week_start.weekday() <= get_household_settings()["shopping_weekday"] <= week_end.weekday(): + hints.append("Der Einkaufstag liegt in dieser Woche schon bereit im Blick.") + return hints[:4] def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: int | None): @@ -971,18 +1453,29 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None 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"])] - 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"]), []) + meal_candidates = dedupe_items( + [item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"] + + [item for item in candidates if item["kind"] == "meal"], + limit=6, + ) + food_candidates = dedupe_items( + [item for item in candidates if item["kind"] == "food" and item["availability_state"] == "home"] + + fetch_recent_plan_items(daypart["id"]) + + [item for item in candidates if item["kind"] == "food"], + limit=20, + ) + entry_item_ids = [int(entry["item_id"]) for entry in entries] sections.append( { "daypart": daypart, "entries": entries, "candidates": candidates, - "quick_items": quick_items, + "meal_candidates": meal_candidates, + "food_candidates": food_candidates, + "recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3), "suggestions": build_daypart_suggestions(daypart["id"]), + "balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids), "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 entries][:2], @@ -1200,6 +1693,7 @@ def apply_day_template(template_id: int, selected_date: date) -> int: template["visibility"], needed_for_date=selected_date.isoformat(), needed_for_daypart_id=daypart_id, + source_item_id=item_id, ) inserted += 1 get_db().commit() @@ -1474,6 +1968,78 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict): ) +def create_or_get_generated_meal( + *, + name: str, + component_ids: list[int], + daypart_id: int, + visibility: str, +) -> int: + existing = get_db().execute( + f""" + SELECT items.id + FROM items + WHERE items.kind = 'meal' AND LOWER(items.name) = LOWER(?) AND {visible_clause('items')} + ORDER BY items.id + LIMIT 1 + """, + [name, *visible_params()], + ).fetchone() + if existing: + return int(existing["id"]) + + cursor = get_db().execute( + """ + INSERT INTO items ( + household_id, owner_user_id, visibility, kind, name, category, created_by, updated_by + ) + VALUES (?, ?, ?, 'meal', ?, ?, ?, ?) + """, + ( + current_household_id(), + g.user["id"], + visibility, + name, + "Kleines Essen", + g.user["id"], + g.user["id"], + ), + ) + meal_id = int(cursor.lastrowid) + sync_item_dayparts(meal_id, [daypart_id]) + sync_meal_components(meal_id, component_ids) + get_db().commit() + return meal_id + + +def insert_plan_entry(*, item_id: int, daypart_id: int, plan_date: date, visibility: str, note: str = "") -> dict: + 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, + plan_date.isoformat(), + daypart_id, + item_id, + note, + g.user["id"], + ), + ) + get_db().commit() + return ensure_item_or_missing_components_are_shopped( + item_id, + g.user["id"], + visibility, + needed_for_date=plan_date.isoformat(), + needed_for_daypart_id=daypart_id, + source_item_id=item_id, + ) + + def planner_template_options(): return fetch_day_templates() @@ -1504,6 +2070,8 @@ def dashboard(): today=today, week_cards=week_cards[:3], dashboard_hints=build_dashboard_hints(today), + recipe_suggestions=build_home_recipe_suggestions(limit=4), + upcoming_entries=fetch_upcoming_shopping_needs(limit=4), day_templates=fetch_day_templates()[:3], week_templates=fetch_week_templates()[:3], ) @@ -1842,6 +2410,176 @@ def item_set_apply(set_id: int): return redirect(url_for("main.shopping_list")) +@main_bp.route("/settings", methods=("GET", "POST")) +@login_required +def settings_view(): + if request.method == "POST": + form_name = request.form.get("form_name", "").strip() + if form_name == "household": + shopping_weekday = normalize_weekday(request.form.get("shopping_weekday"), 5) + raw_prep_days = request.form.get("shopping_prep_days", "1").strip() + shopping_prep_days = max(0, min(7, int(raw_prep_days))) if raw_prep_days.isdigit() else 1 + shopping_reminder_time = request.form.get("shopping_reminder_time", "18:00").strip() or "18:00" + get_db().execute( + """ + UPDATE households + SET shopping_weekday = ?, shopping_prep_days = ?, shopping_reminder_time = ? + WHERE id = ? + """, + (shopping_weekday, shopping_prep_days, shopping_reminder_time, current_household_id()), + ) + get_db().commit() + flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success") + elif form_name == "reminders": + ensure_user_settings_row() + get_db().execute( + """ + UPDATE user_settings + SET reminders_enabled = ?, + push_enabled = ?, + notification_channel = ?, + remind_before_shopping = ?, + remind_on_shopping_day = ?, + show_missing_for_upcoming_week = ?, + show_planned_not_shopped = ?, + remind_tomorrow_if_sparse = ?, + remind_week_if_sparse = ?, + suggest_home_for_today = ?, + remind_small_snack = ?, + remind_nuts = ?, + show_meal_balancing = ?, + suggest_templates = ?, + suggest_patterns = ?, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + """, + ( + parse_checkbox("reminders_enabled", True), + parse_checkbox("push_enabled", False), + normalize_notification_channel(request.form.get("notification_channel"), "in_app"), + parse_checkbox("remind_before_shopping", True), + parse_checkbox("remind_on_shopping_day", True), + parse_checkbox("show_missing_for_upcoming_week", True), + parse_checkbox("show_planned_not_shopped", True), + parse_checkbox("remind_tomorrow_if_sparse", True), + parse_checkbox("remind_week_if_sparse", True), + parse_checkbox("suggest_home_for_today", True), + parse_checkbox("remind_small_snack", False), + parse_checkbox("remind_nuts", False), + parse_checkbox("show_meal_balancing", True), + parse_checkbox("suggest_templates", True), + parse_checkbox("suggest_patterns", True), + g.user["id"], + ), + ) + get_db().commit() + flash("Deine Erinnerungen und Hinweise wurden gespeichert.", "success") + elif form_name == "push_test": + subscription = get_db().execute( + """ + SELECT endpoint, p256dh, auth + FROM push_subscriptions + WHERE user_id = ? AND is_active = 1 + ORDER BY updated_at DESC + LIMIT 1 + """, + (g.user["id"],), + ).fetchone() + if subscription is None: + flash("Bitte aktiviere zuerst Push im Browser oder auf dem Home-Bildschirm.", "error") + else: + ok, error = send_push_message( + { + "endpoint": subscription["endpoint"], + "keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]}, + }, + title="Nouri", + body="Push ist bereit. Erinnerungen können später darüber kommen.", + url=url_for("main.settings_view", _external=True), + ) + if ok: + get_db().execute( + "UPDATE push_subscriptions SET last_test_at = CURRENT_TIMESTAMP WHERE endpoint = ?", + (subscription["endpoint"],), + ) + get_db().commit() + flash("Die Test-Mitteilung wurde versendet.", "success") + else: + flash(error or "Die Test-Mitteilung konnte gerade nicht gesendet werden.", "error") + return redirect(url_for("main.settings_view")) + + household_settings = get_household_settings() + user_settings = get_user_settings() + push_subscription = get_db().execute( + """ + SELECT COUNT(*) AS count + FROM push_subscriptions + WHERE user_id = ? AND is_active = 1 + """, + (g.user["id"],), + ).fetchone() + return render_template( + "settings.html", + household_settings=household_settings, + user_settings=user_settings, + push_subscription_count=int(push_subscription["count"]), + push_ready=push_is_configured(), + push_public_key_value=push_public_key(), + ) + + +@main_bp.post("/push/subscribe") +@login_required +def push_subscribe(): + if not push_is_configured(): + return jsonify({"ok": False, "error": "Push ist noch nicht konfiguriert."}), 400 + endpoint = request.form.get("endpoint", "").strip() + p256dh = request.form.get("p256dh", "").strip() + auth_key = request.form.get("auth", "").strip() + if not endpoint or not p256dh or not auth_key: + return jsonify({"ok": False, "error": "Unvollständige Push-Daten."}), 400 + + get_db().execute( + """ + INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent, is_active) + VALUES (?, ?, ?, ?, ?, 1) + ON CONFLICT(endpoint) DO UPDATE SET + user_id = excluded.user_id, + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + is_active = 1, + updated_at = CURRENT_TIMESTAMP + """, + (g.user["id"], endpoint, p256dh, auth_key, request.headers.get("User-Agent", "")), + ) + ensure_user_settings_row() + get_db().execute( + "UPDATE user_settings SET push_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", + (g.user["id"],), + ) + get_db().commit() + return jsonify({"ok": True}) + + +@main_bp.post("/push/unsubscribe") +@login_required +def push_unsubscribe(): + endpoint = request.form.get("endpoint", "").strip() + if endpoint: + get_db().execute( + "UPDATE push_subscriptions SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE endpoint = ? AND user_id = ?", + (endpoint, g.user["id"]), + ) + else: + get_db().execute( + "UPDATE push_subscriptions SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?", + (g.user["id"],), + ) + get_db().commit() + return jsonify({"ok": True}) + + @main_bp.route("/items/") @login_required def item_list(kind: str): @@ -1884,7 +2622,7 @@ def item_create(kind: str): return redirect(url_for("main.dashboard")) form_data = { - "name": "", + "name": request.args.get("name", "").strip(), "category": "", "note": "", "visibility": "shared", @@ -1892,7 +2630,7 @@ def item_create(kind: str): "target_user_raw": TARGET_USER_OPTIONS_DEFAULT, "food_search": "", "daypart_ids": [], - "component_ids": [], + "component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()], "quick_food_name": "", "quick_food_category": "", "quick_food_note": "", @@ -2074,6 +2812,8 @@ def item_add_to_shopping(item_id: int): 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") + elif result["scheduled_count"]: + flash(f"Für „{item['name']}“ sind fehlende Lebensmittel für einen späteren Einkauf vorgemerkt.", "info") else: flash(f"Für „{item['name']}“ ist gerade nichts zusätzlich nötig.", "info") return redirect(request.referrer or url_for("main.shopping_list")) @@ -2153,6 +2893,8 @@ def shopping_list(): ) if result["count"]: flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}.", "success") + elif result["scheduled_count"]: + flash("Ein paar Dinge sind für einen späteren Einkauf vorgemerkt.", "info") else: flash("Dafür ist gerade nichts zusätzlich nötig.", "info") except ValueError as exc: @@ -2160,9 +2902,19 @@ def shopping_list(): return redirect(url_for("main.shopping_list")) entries = fetch_shopping_entries() + upcoming_entries = fetch_upcoming_shopping_needs() addable_items = fetch_items(include_archived=False) addable_items = [item for item in addable_items if not item["is_on_shopping_list"]] - return render_template("shopping/list.html", entries=entries, addable_items=addable_items) + household_settings = get_household_settings() + shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt") + return render_template( + "shopping/list.html", + entries=entries, + upcoming_entries=upcoming_entries, + addable_items=addable_items, + household_settings=household_settings, + shopping_weekday_label=shopping_weekday_label, + ) @main_bp.post("/shopping//check") @@ -2249,6 +3001,7 @@ def home_view(): return render_template( "home/list.html", sections=build_home_sections(items, dayparts, daypart_id), + recipe_suggestions=build_home_recipe_suggestions(daypart_id, limit=4), query=query, dayparts=dayparts, selected_daypart_id=daypart_id, @@ -2298,6 +3051,8 @@ def planner(): today=date.today(), week_templates=fetch_week_templates()[:6], week_hints=build_week_hints(week_start), + upcoming_entries=fetch_upcoming_shopping_needs(limit=8), + household_settings=get_household_settings(), ) @@ -2321,32 +3076,17 @@ def planner_day(): daypart_id = int(daypart_id_raw) try: 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, - needed_for_date=selected_date.isoformat(), - needed_for_daypart_id=daypart_id, + shopping_result = insert_plan_entry( + item_id=item_id, + daypart_id=daypart_id, + plan_date=selected_date, + visibility=visibility, + note=note, ) if shopping_result["count"]: - flash("Fehlende Dinge wurden zusätzlich auf die Einkaufsliste gesetzt.", "info") + flash("Fehlende Lebensmittel wurden für den nächsten Einkauf ergänzt.", "info") + elif shopping_result["scheduled_count"]: + flash("Fehlende Lebensmittel wurden für einen späteren Einkauf vorgemerkt.", "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}" @@ -2371,6 +3111,39 @@ def planner_day(): ) +@main_bp.post("/planner/day/generated-meal") +@login_required +def planner_generated_meal(): + selected_date = parse_plan_date(request.form.get("plan_date")) + daypart_raw = request.form.get("daypart_id", "").strip() + meal_name = request.form.get("meal_name", "").strip() + component_ids = [int(value) for value in request.form.getlist("component_ids") if value.isdigit()] + visibility = normalize_visibility(request.form.get("visibility"), "shared") + if not daypart_raw.isdigit() or not meal_name or not component_ids: + flash("Die vorgeschlagene Kombination konnte gerade nicht übernommen werden.", "error") + return redirect(url_for("main.planner_day", date=selected_date.isoformat())) + + daypart_id = int(daypart_raw) + meal_id = create_or_get_generated_meal( + name=meal_name, + component_ids=component_ids, + daypart_id=daypart_id, + visibility=visibility, + ) + shopping_result = insert_plan_entry( + item_id=meal_id, + daypart_id=daypart_id, + plan_date=selected_date, + visibility=visibility, + ) + if shopping_result["count"]: + flash("Die Kombination wurde eingeplant und fehlende Lebensmittel wurden ergänzt.", "info") + elif shopping_result["scheduled_count"]: + flash("Die Kombination wurde eingeplant. Fehlende Lebensmittel sind für später vorgemerkt.", "info") + flash("Die Kombination wurde als Mahlzeitenidee übernommen.", "success") + return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}") + + @main_bp.post("/planner//remove") @login_required def planner_remove(entry_id: int): @@ -2440,9 +3213,12 @@ def planner_move(entry_id: int): entry["visibility"], needed_for_date=target_date.isoformat(), needed_for_daypart_id=target_daypart_id, + source_item_id=entry["item_id"], ) if shopping_result["count"]: - flash("Fehlende Dinge wurden nach dem Verschieben auf die Einkaufsliste gesetzt.", "info") + flash("Fehlende Lebensmittel wurden nach dem Verschieben ergänzt.", "info") + elif shopping_result["scheduled_count"]: + flash("Fehlende Lebensmittel sind für den passenden Einkauf vorgemerkt.", "info") return jsonify( { "ok": True, diff --git a/nouri/push.py b/nouri/push.py new file mode 100644 index 0000000..f6f3369 --- /dev/null +++ b/nouri/push.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import json +from typing import Any + +from flask import current_app + + +def push_is_configured() -> bool: + return bool( + current_app.config.get("VAPID_PUBLIC_KEY") + and current_app.config.get("VAPID_PRIVATE_KEY") + and current_app.config.get("VAPID_SUBJECT") + ) + + +def push_public_key() -> str | None: + return current_app.config.get("VAPID_PUBLIC_KEY") or None + + +def send_push_message(subscription: dict[str, Any], *, title: str, body: str, url: str) -> tuple[bool, str | None]: + if not push_is_configured(): + return False, "Push ist noch nicht konfiguriert." + + try: + from pywebpush import WebPushException, webpush + except Exception: + return False, "Die Push-Bibliothek ist noch nicht installiert." + + payload = json.dumps( + { + "title": title, + "body": body, + "url": url, + "icon": "/static/brand/pwa-192.png", + "badge": "/static/brand/pwa-badge.png", + } + ) + + try: + webpush( + subscription_info=subscription, + data=payload, + vapid_private_key=current_app.config["VAPID_PRIVATE_KEY"], + vapid_claims={"sub": current_app.config["VAPID_SUBJECT"]}, + ) + except WebPushException as exc: # pragma: no cover - depends on live push endpoint + return False, str(exc) + + return True, None diff --git a/nouri/schema.sql b/nouri/schema.sql index 09d888f..c712481 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -3,6 +3,9 @@ PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS households ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, + shopping_weekday INTEGER NOT NULL DEFAULT 5, + shopping_prep_days INTEGER NOT NULL DEFAULT 1, + shopping_reminder_time TEXT NOT NULL DEFAULT '18:00', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); @@ -28,6 +31,7 @@ CREATE TABLE IF NOT EXISTS household_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, household_id INTEGER NOT NULL, name TEXT NOT NULL, + builder_key TEXT NOT NULL DEFAULT 'neutral', sort_order INTEGER NOT NULL DEFAULT 100, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -35,6 +39,41 @@ CREATE TABLE IF NOT EXISTS household_categories ( FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS user_settings ( + user_id INTEGER PRIMARY KEY, + reminders_enabled INTEGER NOT NULL DEFAULT 1, + push_enabled INTEGER NOT NULL DEFAULT 0, + notification_channel TEXT NOT NULL DEFAULT 'in_app', + remind_before_shopping INTEGER NOT NULL DEFAULT 1, + remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, + show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1, + show_planned_not_shopped INTEGER NOT NULL DEFAULT 1, + remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1, + remind_week_if_sparse INTEGER NOT NULL DEFAULT 1, + suggest_home_for_today INTEGER NOT NULL DEFAULT 1, + remind_small_snack INTEGER NOT NULL DEFAULT 0, + remind_nuts INTEGER NOT NULL DEFAULT 0, + show_meal_balancing INTEGER NOT NULL DEFAULT 1, + suggest_templates INTEGER NOT NULL DEFAULT 1, + suggest_patterns INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_test_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS dayparts ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, @@ -106,6 +145,29 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item ON shopping_entries (item_id) WHERE is_checked = 0; +CREATE TABLE IF NOT EXISTS shopping_needs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + household_id INTEGER NOT NULL, + owner_user_id INTEGER, + visibility TEXT NOT NULL DEFAULT 'shared', + item_id INTEGER NOT NULL, + source_item_id INTEGER, + needed_for_date TEXT NOT NULL, + needed_for_daypart_id INTEGER, + activation_date TEXT NOT NULL, + is_activated INTEGER NOT NULL DEFAULT 0, + activated_at TEXT, + created_by INTEGER, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility), + FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE, + FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, + FOREIGN KEY (source_item_id) REFERENCES items(id) ON DELETE SET NULL, + FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); + CREATE TABLE IF NOT EXISTS plan_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, household_id INTEGER, @@ -218,6 +280,9 @@ 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_shopping_needs_household_activation +ON shopping_needs (household_id, activation_date, is_activated); + CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility ON day_templates (household_id, visibility, name); diff --git a/nouri/static/brand/pwa-192.png b/nouri/static/brand/pwa-192.png new file mode 100644 index 0000000000000000000000000000000000000000..24f0a95f71752a77bfb33095c3d4baa745166921 GIT binary patch literal 10148 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 literal 0 HcmV?d00001 diff --git a/nouri/static/brand/pwa-512.png b/nouri/static/brand/pwa-512.png new file mode 100644 index 0000000000000000000000000000000000000000..145a5cf01c24b29189438fc23f9548b1b4c09470 GIT binary patch literal 34400 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 literal 0 HcmV?d00001 diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index ba33131..f3e5bdd 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -128,6 +128,29 @@ button.secondary:hover, margin: 1rem auto 2rem; } +.site-footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.75rem; + padding: 1rem 0.35rem 0; + color: var(--muted); + font-size: 0.92rem; +} + +.footer-copy { + display: inline-flex; + align-items: center; + gap: 0.55rem; + flex-wrap: wrap; +} + +.footer-copy .ui-icon { + width: 0.95rem; + height: 0.95rem; + color: var(--accent-strong); +} + .site-header { position: sticky; top: 1rem; @@ -445,6 +468,7 @@ h3 { justify-content: space-between; gap: 1rem; align-items: center; + padding: 1rem 1.1rem; } .stacked-mobile { @@ -616,6 +640,7 @@ input[type="text"], input[type="email"], input[type="password"], input[type="date"], +input[type="time"], input[type="file"], select, textarea { @@ -862,6 +887,111 @@ legend { margin-bottom: 1rem; } +.planner-subsection { + display: grid; + gap: 0.8rem; + margin-bottom: 1rem; +} + +.planner-subsection h3 { + font-size: 1rem; +} + +.planner-search { + display: grid; + gap: 0.45rem; + color: var(--muted); +} + +.compact-picker-list { + display: grid; + gap: 0.55rem; +} + +.compact-picker-list form[hidden] { + display: none; +} + +.picker-row { + width: 100%; + justify-content: space-between; + padding: 0.85rem 1rem; + border-radius: 16px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%); + color: var(--text); +} + +.picker-row small { + color: var(--muted); +} + +.compact-quick-row { + margin-bottom: 0; +} + +.compact-button { + min-width: 150px; + padding: 0.78rem 0.9rem; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.pwa-card { + padding: 1rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%); +} + +.card-link-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +.menu-card { + display: grid; + justify-items: start; + gap: 0.5rem; + padding: 1rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%); +} + +.menu-card .ui-icon { + width: 1.15rem; + height: 1.15rem; + color: var(--accent-strong); +} + +.roomy-row { + padding: 1rem 1.2rem; +} + +.inline-form-tight { + grid-template-columns: 1fr auto; +} + +.inline-form-tight > :first-child { + grid-column: auto; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + .planner-entry-top { align-items: flex-start; } @@ -1018,6 +1148,41 @@ legend { mask-image: url("../icons/fa/ellipsis.svg"); } +.icon-heart { + -webkit-mask-image: url("../icons/fa/heart.svg"); + mask-image: url("../icons/fa/heart.svg"); +} + +.icon-sliders { + -webkit-mask-image: url("../icons/fa/sliders.svg"); + mask-image: url("../icons/fa/sliders.svg"); +} + +.icon-seedling { + -webkit-mask-image: url("../icons/fa/seedling.svg"); + mask-image: url("../icons/fa/seedling.svg"); +} + +.icon-bell { + -webkit-mask-image: url("../icons/fa/bell.svg"); + mask-image: url("../icons/fa/bell.svg"); +} + +.icon-mobile-screen-button { + -webkit-mask-image: url("../icons/fa/mobile-screen-button.svg"); + mask-image: url("../icons/fa/mobile-screen-button.svg"); +} + +.icon-apple-whole { + -webkit-mask-image: url("../icons/fa/apple-whole.svg"); + mask-image: url("../icons/fa/apple-whole.svg"); +} + +.icon-leaf { + -webkit-mask-image: url("../icons/fa/leaf.svg"); + mask-image: url("../icons/fa/leaf.svg"); +} + .mobile-sheet-backdrop { position: fixed; inset: 0; @@ -1054,17 +1219,10 @@ legend { .mobile-sheet-links { display: grid; - gap: 0.45rem; + gap: 0.75rem; 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; } @@ -1091,6 +1249,7 @@ legend { .stats-grid, .two-column, .template-library-grid, + .settings-grid, .inline-form, .planner-entry-form, .planner-entry-form-wide, @@ -1158,6 +1317,10 @@ legend { padding: 1rem; } + .site-footer { + padding-bottom: 5.6rem; + } + h1 { font-size: clamp(1.6rem, 7vw, 2rem); } @@ -1174,7 +1337,9 @@ legend { .week-mini-grid, .week-overview-grid, .more-link-grid, - .template-library-grid { + .template-library-grid, + .settings-grid, + .card-link-grid { grid-template-columns: 1fr; } diff --git a/nouri/static/icons/fa/apple-whole.svg b/nouri/static/icons/fa/apple-whole.svg new file mode 100644 index 0000000..dd184e4 --- /dev/null +++ b/nouri/static/icons/fa/apple-whole.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/bell.svg b/nouri/static/icons/fa/bell.svg new file mode 100644 index 0000000..63352d3 --- /dev/null +++ b/nouri/static/icons/fa/bell.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/heart.svg b/nouri/static/icons/fa/heart.svg new file mode 100644 index 0000000..c029a4b --- /dev/null +++ b/nouri/static/icons/fa/heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/leaf.svg b/nouri/static/icons/fa/leaf.svg new file mode 100644 index 0000000..bfe8bef --- /dev/null +++ b/nouri/static/icons/fa/leaf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/mobile-screen-button.svg b/nouri/static/icons/fa/mobile-screen-button.svg new file mode 100644 index 0000000..3a2eb40 --- /dev/null +++ b/nouri/static/icons/fa/mobile-screen-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/seedling.svg b/nouri/static/icons/fa/seedling.svg new file mode 100644 index 0000000..514a73c --- /dev/null +++ b/nouri/static/icons/fa/seedling.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/sliders.svg b/nouri/static/icons/fa/sliders.svg new file mode 100644 index 0000000..3ce0f97 --- /dev/null +++ b/nouri/static/icons/fa/sliders.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/js/pwa.js b/nouri/static/js/pwa.js new file mode 100644 index 0000000..8d629ff --- /dev/null +++ b/nouri/static/js/pwa.js @@ -0,0 +1,96 @@ +(() => { + const getCsrfToken = () => { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute("content") : ""; + }; + + const getPushPublicKey = () => { + const meta = document.querySelector('meta[name="nouri-push-public-key"]'); + return meta ? meta.getAttribute("content") : ""; + }; + + const urlBase64ToUint8Array = (base64String) => { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + return Uint8Array.from([...rawData].map((character) => character.charCodeAt(0))); + }; + + const registerServiceWorker = async () => { + if (!("serviceWorker" in navigator)) return null; + return navigator.serviceWorker.register("/service-worker.js"); + }; + + const subscribeToPush = async () => { + const publicKey = getPushPublicKey(); + if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return; + const registration = await navigator.serviceWorker.ready; + const permission = await Notification.requestPermission(); + if (permission !== "granted") return; + + const existing = await registration.pushManager.getSubscription(); + const subscription = existing || await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + const subscriptionJson = subscription.toJSON(); + const payload = new URLSearchParams({ + csrf_token: getCsrfToken(), + endpoint: subscription.endpoint, + p256dh: subscriptionJson.keys && subscriptionJson.keys.p256dh ? subscriptionJson.keys.p256dh : "", + auth: subscriptionJson.keys && subscriptionJson.keys.auth ? subscriptionJson.keys.auth : "", + }); + await fetch("/push/subscribe", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + }, + body: payload.toString(), + }); + window.location.reload(); + }; + + const unsubscribeFromPush = async () => { + if (!("serviceWorker" in navigator)) return; + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + const payload = new URLSearchParams({ csrf_token: getCsrfToken() }); + if (subscription) { + payload.set("endpoint", subscription.endpoint); + await subscription.unsubscribe(); + } + await fetch("/push/unsubscribe", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + }, + body: payload.toString(), + }); + window.location.reload(); + }; + + document.addEventListener("DOMContentLoaded", () => { + registerServiceWorker(); + + const enableButton = document.querySelector("[data-push-enable]"); + const disableButton = document.querySelector("[data-push-disable]"); + + if (enableButton) { + enableButton.addEventListener("click", () => { + subscribeToPush().catch(() => { + window.location.reload(); + }); + }); + } + + if (disableButton) { + disableButton.addEventListener("click", () => { + unsubscribeFromPush().catch(() => { + window.location.reload(); + }); + }); + } + }); +})(); diff --git a/nouri/static/pwa/app.webmanifest b/nouri/static/pwa/app.webmanifest new file mode 100644 index 0000000..4a89c98 --- /dev/null +++ b/nouri/static/pwa/app.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "Nouri", + "short_name": "Nouri", + "description": "einfach essen planen", + "lang": "de", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#fff6ef", + "theme_color": "#efab72", + "icons": [ + { + "src": "/static/brand/pwa-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/brand/pwa-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/nouri/static/pwa/service-worker.js b/nouri/static/pwa/service-worker.js new file mode 100644 index 0000000..669a146 --- /dev/null +++ b/nouri/static/pwa/service-worker.js @@ -0,0 +1,74 @@ +const CACHE_NAME = "nouri-v0-5-0"; +const APP_SHELL = [ + "/", + "/static/css/styles.css", + "/static/js/theme.js", + "/static/js/ui.js", + "/static/js/planner.js", + "/static/js/pwa.js", + "/static/brand/pwa-192.png", + "/static/brand/pwa-512.png", + "/static/brand/favicon.svg", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + event.respondWith( + caches.match(event.request).then((cached) => { + if (cached) return cached; + return fetch(event.request).then((response) => { + if (!response || response.status !== 200 || response.type !== "basic") { + return response; + } + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); + return response; + }); + }) + ); +}); + +self.addEventListener("push", (event) => { + if (!event.data) return; + const data = event.data.json(); + event.waitUntil( + self.registration.showNotification(data.title || "Nouri", { + body: data.body || "", + icon: data.icon || "/static/brand/pwa-192.png", + badge: data.badge || "/static/brand/pwa-badge.png", + data: { url: data.url || "/" }, + }) + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const targetUrl = event.notification.data && event.notification.data.url ? event.notification.data.url : "/"; + event.waitUntil( + clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if (client.url.includes(targetUrl) && "focus" in client) { + return client.focus(); + } + } + if (clients.openWindow) { + return clients.openWindow(targetUrl); + } + return null; + }) + ); +}); diff --git a/nouri/templates/admin/categories.html b/nouri/templates/admin/categories.html index f6cb5f4..db7f2e3 100644 --- a/nouri/templates/admin/categories.html +++ b/nouri/templates/admin/categories.html @@ -17,6 +17,14 @@ Neue Kategorie + @@ -26,8 +34,12 @@
{{ category.name }} -

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

+

+ {% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %} + · {{ builder_descriptions[category.builder_key] }} +

+ {{ builder_descriptions[category.builder_key].split('.')[0] }} {% if category.is_active %} Aktiv {% else %} @@ -36,6 +48,18 @@
+
+ {{ csrf_input() }} + + +
{{ csrf_input() }}
+
+ +
+
diff --git a/nouri/templates/base.html b/nouri/templates/base.html index f3d55f0..3142b0f 100644 --- a/nouri/templates/base.html +++ b/nouri/templates/base.html @@ -4,12 +4,20 @@ {% block title %}Nouri{% endblock %} + + + + + + + +
-