6 Commits

24 changed files with 1333 additions and 109 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz", "author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning", "description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen", "tagline": "einfach essen planen",
"version": "0.6.0", "version": "1.1.1",
"upstreamVersion": "0.6.0", "upstreamVersion": "1.1.1",
"healthCheckPath": "/", "healthCheckPath": "/",
"httpPort": 8000, "httpPort": 8000,
"manifestVersion": 2, "manifestVersion": 2,
+89 -17
View File
@@ -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. 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.5 ## Merkmale in Version 1.0.0
- Lebensmittel und Mahlzeitenideen anlegen - Lebensmittel und Mahlzeitenideen anlegen
- Fotos lokal hochladen - Fotos lokal hochladen
@@ -22,12 +22,8 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
- ruhige 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 - globale Kategorien pro Haushalt
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten - „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 - Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
- ausgewogene Ergänzungsvorschläge auf Basis ruhiger Bausteine - PWA-Grundlage mit Web App Manifest, Service Worker und optionalem Web Push
- 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 ## Lokal starten
@@ -47,28 +43,104 @@ Die App legt Daten standardmäßig unter `./data` ab.
Wichtige Umgebungsvariablen: Wichtige Umgebungsvariablen:
- `NOURI_SECRET_KEY`: Session-Secret für Produktion - `NOURI_SECRET_KEY`: Session-Secret für Produktion
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron - `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, lokal standardmäßig `./data`, auf Cloudron `/app/data`
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
- `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen
- `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin`
- `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push - `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_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push
- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io` - `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io`
## Migration von 0.4 auf 0.5 ## Migration und Datenhaltung
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. Beim Start erweitert Nouri das SQLite-Schema pragmatisch direkt weiter. Vorhandene Daten bleiben dabei erhalten und werden weiterverwendet.
## Cloudron-Hinweis Wichtig für die Trennung zwischen lokal und Produktion:
Für Cloudron ist die App jetzt so vorbereitet, dass Datenbank und Uploads unter `/app/data` liegen. Das Startskript setzt `NOURI_DATA_DIR=/app/data`, legt die SQLite-Datei dort an und startet die App per `gunicorn`.
Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt:
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data` - lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
- auf Cloudron nutzt Nouri `/app/data` - auf Cloudron nutzt Nouri `/app/data`
- `data/` ist in `.gitignore` und `.dockerignore` ausgeschlossen und wird weder eingecheckt noch ins Image kopiert - `data/` ist nicht für Git oder das Paket gedacht
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten - produktive Daten unter `/app/data` bleiben bei 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. ## Cloudron
Nouri ist so vorbereitet, dass Code und persistente Daten sauber getrennt bleiben:
- Code liegt im Container unter `/app/code`
- persistente Daten liegen unter `/app/data`
- Datenbank und Uploads werden nicht aus dem lokalen `./data` nach Produktion übernommen
- Updates ersetzen den Code, aber nicht die produktiven Inhalte in `/app/data`
### Neu installieren
1. Paket oder Image bauen und nach Cloudron hochladen.
2. Die App einmal per Cloudron installieren.
3. Nach dem ersten Start Nouri öffnen.
4. Den ersten Haushalt-Zugang unter `/auth/setup` anlegen.
5. Danach Push, Erinnerungen und Einkaufstag in den Optionen einrichten.
### Bestehende Installation aktualisieren
Wenn Nouri bereits installiert ist, bitte **kein neues `cloudron install`** ausführen.
Stattdessen die bestehende App aktualisieren, zum Beispiel mit:
```bash
cloudron update --no-backup --app <deine-app> --server <dein-server> --token <dein-token>
```
Dabei gilt:
- produktive Daten unter `/app/data` bleiben erhalten
- lokale Testdaten aus `./data` werden nicht mit hochgeladen
- die bestehende Installation läuft mit demselben persistenten Datenordner weiter
### Wichtige Cloudron-Variablen
Für eine saubere produktive Installation sind diese Werte sinnvoll:
- `NOURI_DATA_DIR=/app/data`
- `NOURI_SECURE_COOKIES=1`
- `NOURI_TIMEZONE=Europe/Berlin`
- `NOURI_SECRET_KEY=<eigenes-secret>`
- `NOURI_VAPID_PUBLIC_KEY=<public-key>`
- `NOURI_VAPID_PRIVATE_KEY=<private-key>`
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
## Push einrichten
### 1. VAPID-Schlüssel erzeugen
Die Schritte dafür stehen kompakt in [PUSH_SETUP.md](PUSH_SETUP.md).
### 2. VAPID-Werte in Cloudron setzen
Zum Beispiel so:
```bash
cloudron env set --app <deine-app> \
NOURI_VAPID_PUBLIC_KEY='...' \
NOURI_VAPID_PRIVATE_KEY='...' \
NOURI_VAPID_SUBJECT='mailto:mail@hnz.io'
```
Danach die App neu starten:
```bash
cloudron restart --app <deine-app>
```
### 3. Push auf dem Gerät aktivieren
1. Nouri im Browser oder auf dem iPhone öffnen.
2. In `Optionen` auf **Push erlauben** tippen.
3. Optional eine Test-Mitteilung senden.
4. Auf dem iPhone Nouri am besten zusätzlich zum Home-Bildschirm hinzufügen.
Wichtig:
- Push funktioniert nur auf Geräten, die sich einmal aktiv registriert haben.
- Ohne VAPID-Werte bleibt Push bewusst deaktiviert.
- Die Browser- oder iPhone-Freigabe allein reicht nicht: Hinweise müssen zusätzlich in Nouri eingeschaltet sein.
## Lizenz ## Lizenz
+56
View File
@@ -0,0 +1,56 @@
# Nouri 1.0.0
Nouri 1.0.0 fasst die ruhige Alltagsplanung, gemeinsame Nutzung im Haushalt und die ersten echten Push-Erinnerungen in einen stabileren Release-Stand zusammen. Der Fokus dieser Version liegt auf Verlässlichkeit, klareren Vorschlägen und einem saubereren produktiven Betrieb auf Cloudron.
## Highlights
- Vorschläge lassen sich jetzt gezielter über drei Modi steuern:
- `Eher ausgewogen`
- `Fitness`
- `Proteinbetont`
- Lebensmittel können zusätzlich als `Eher leicht`, `Neutral` oder `Eher gehaltvoll` markiert werden.
- Push-Erinnerungen für fehlendes Frühstück, Mittagessen und Abendessen sind vorbereitet und in den Optionen pro Nutzer schaltbar.
- Push öffnet direkt den passenden Tagesplan und kann eine schon vorbereitete Mahlzeitenidee mitbringen.
- Saaten sind in der Kategorien-Logik jetzt separat unter `Passt eher zu` auswählbar.
## Planung und Vorschläge
- Nouri bevorzugt bei Vorschlägen weiterhin zuerst vorhandene Mahlzeitenideen.
- Wenn noch keine passende Mahlzeitenidee da ist, werden Kombinationen aus den zuhause vorhandenen Lebensmitteln vorgeschlagen.
- Für `Fitness` werden proteinbetonte und eher leichte Kombinationen bevorzugt.
- Für `Proteinbetont` werden proteinreiche Kombinationen priorisiert, ohne zusätzlich auf „leicht“ zu ziehen.
- Im Tagesplan können vorbereitete Vorschläge direkter übernommen werden.
## Erinnerungen und Push
- Neue Push-Schalter in den Optionen:
- Frühstück um `08:00`
- Mittagessen um `12:00`
- Abendessen um `18:00`
- Die Push-Nachricht enthält nach Möglichkeit direkt einen passenden Vorschlag.
- Für den Versand wurde ein kleiner Reminder-Worker ergänzt.
- Push bleibt weiterhin optional und funktioniert nur auf Geräten, die sich aktiv registriert haben.
## Sicherheit und Stabilität
- Passwortprüfung wurde angezogen.
- Sichere Cookie-Konfiguration für HTTPS ist vorbereitet.
- Zusätzliche Sicherheitsheader werden gesetzt.
- Die Schema-Upgrades wurden weitergezogen, damit neue Einstellungen und Reminder-Daten sauber in bestehende Installationen einlaufen.
## Cloudron und Betrieb
- README wurde für Cloudron klarer dokumentiert:
- Neuinstallation
- Update statt Neuinstallation
- persistente Daten unter `/app/data`
- wichtige Umgebungsvariablen
- VAPID- und Push-Einrichtung
- Cloudron-Paketversion und App-Version stehen jetzt auf `1.0.0`.
- Der Service Worker nutzt einen neuen Cache-Namen für den Release-Stand `1.0.0`.
## Upgrade-Hinweis
- Bestehende SQLite-Daten bleiben erhalten.
- Neue Felder und Tabellen werden beim Start ergänzt.
- Nach dem Update lohnt sich ein kurzer Blick in `Optionen`, damit Vorschlagsstil, Push und Erinnerungen einmal bewusst gesetzt sind.
+27
View File
@@ -0,0 +1,27 @@
# Nouri 1.1.1
Nouri 1.1.1 ist ein kleiner Feinschliff-Release. Der Schwerpunkt liegt auf saubereren Bezeichnungen in der Oberfläche und einer einheitlichen Versionsanhebung für App und Cloudron-Paket.
## Highlights
- Beschriftungen im Plan werden wieder korrekt großgeschrieben, zum Beispiel `Mahlzeitideen`
- App-Version und Cloudron-Version stehen jetzt auf `1.1.1`
- Versions-Fallback in der App wurde an den neuen Stand angepasst
## Neu in 1.1.1
### Oberfläche
- Die automatisch kleingeschriebene Anzeige im Tagesplan wurde korrigiert.
- Begriffe wie `Mahlzeitideen` erscheinen wieder so, wie sie in der App gedacht sind.
### Versionierung
- `CloudronManifest.json` wurde auf `1.1.1` angehoben.
- Der interne App-Fallback in `nouri/__init__.py` wurde ebenfalls auf `1.1.1` gesetzt.
- Die Schema-Version in `nouri/db.py` folgt jetzt ebenfalls `1.1.1`.
## Cloudron
- Das Update kann sauber als neue Version ausgerollt werden.
- Footer, Release-Link und Versionsanzeige greifen damit wieder auf einen konsistenten Stand zu.
+45 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import secrets import secrets
from datetime import date, timedelta from datetime import date, timedelta
@@ -16,10 +17,14 @@ from .constants import (
BUILDER_OPTIONS, BUILDER_OPTIONS,
DAYPARTS, DAYPARTS,
DEFAULT_CATEGORIES, DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS,
ITEM_KIND_LABELS, ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS, ITEM_KIND_SINGULAR_LABELS,
NOTIFICATION_CHANNEL_OPTIONS, NOTIFICATION_CHANNEL_OPTIONS,
ROLE_LABELS, ROLE_LABELS,
SUGGESTION_STYLE_LABELS,
SUGGESTION_STYLE_OPTIONS,
VISIBILITY_DESCRIPTIONS, VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS, VISIBILITY_LABELS,
WEEKDAY_OPTIONS, WEEKDAY_OPTIONS,
@@ -30,6 +35,7 @@ from .main import main_bp
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases"
def load_secret_key(data_dir: Path) -> str: def load_secret_key(data_dir: Path) -> str:
@@ -50,11 +56,38 @@ def load_secret_key(data_dir: Path) -> str:
return secret_value return secret_value
def load_app_version(root_dir: Path) -> str:
env_version = os.environ.get("NOURI_APP_VERSION", "").strip()
if env_version:
return env_version
manifest_path = root_dir / "CloudronManifest.json"
if manifest_path.exists():
try:
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
manifest_data = {}
manifest_version = str(
manifest_data.get("upstreamVersion")
or manifest_data.get("version")
or ""
).strip()
if manifest_version:
return manifest_version
return "1.1.1"
def load_release_url() -> str:
return os.environ.get("NOURI_RELEASE_URL", DEFAULT_RELEASE_URL).strip() or DEFAULT_RELEASE_URL
def create_app() -> Flask: def create_app() -> Flask:
root_dir = Path(__file__).resolve().parent.parent root_dir = Path(__file__).resolve().parent.parent
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve() data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
upload_dir = data_dir / "uploads" upload_dir = data_dir / "uploads"
db_path = data_dir / "nouri.sqlite3" db_path = data_dir / "nouri.sqlite3"
app_version = load_app_version(root_dir)
release_url = load_release_url()
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
ensure_upload_structure(upload_dir) ensure_upload_structure(upload_dir)
@@ -69,7 +102,10 @@ def create_app() -> Flask:
PERMANENT_SESSION_LIFETIME=timedelta(days=30), PERMANENT_SESSION_LIFETIME=timedelta(days=30),
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SAMESITE="Lax",
APP_VERSION="0.6.0", SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
APP_VERSION=app_version,
RELEASE_URL=release_url,
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"), VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
@@ -97,6 +133,10 @@ def create_app() -> Flask:
"builder_descriptions": BUILDER_DESCRIPTIONS, "builder_descriptions": BUILDER_DESCRIPTIONS,
"builder_options": BUILDER_OPTIONS, "builder_options": BUILDER_OPTIONS,
"daypart_suggestions": DAYPARTS, "daypart_suggestions": DAYPARTS,
"energy_density_options": ENERGY_DENSITY_OPTIONS,
"energy_density_labels": ENERGY_DENSITY_LABELS,
"suggestion_style_options": SUGGESTION_STYLE_OPTIONS,
"suggestion_style_labels": SUGGESTION_STYLE_LABELS,
"visibility_labels": VISIBILITY_LABELS, "visibility_labels": VISIBILITY_LABELS,
"visibility_descriptions": VISIBILITY_DESCRIPTIONS, "visibility_descriptions": VISIBILITY_DESCRIPTIONS,
"role_labels": ROLE_LABELS, "role_labels": ROLE_LABELS,
@@ -104,6 +144,7 @@ def create_app() -> Flask:
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS, "notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
"today": date.today(), "today": date.today(),
"app_version": app.config["APP_VERSION"], "app_version": app.config["APP_VERSION"],
"app_release_url": app.config["RELEASE_URL"],
"push_public_key": app.config["VAPID_PUBLIC_KEY"], "push_public_key": app.config["VAPID_PUBLIC_KEY"],
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]), "push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
@@ -159,6 +200,9 @@ def create_app() -> Flask:
content_type = response.headers.get("Content-Type", "") content_type = response.headers.get("Content-Type", "")
if content_type.startswith("text/html"): if content_type.startswith("text/html"):
response.headers.setdefault("Cache-Control", "no-store, max-age=0") response.headers.setdefault("Cache-Control", "no-store, max-age=0")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
return response return response
@app.errorhandler(413) @app.errorhandler(413)
+28
View File
@@ -289,3 +289,31 @@ def category_update(category_id: int):
get_db().commit() get_db().commit()
flash("Die Zuordnung wurde aktualisiert.", "success") flash("Die Zuordnung wurde aktualisiert.", "success")
return redirect(url_for("admin.category_settings")) return redirect(url_for("admin.category_settings"))
@admin_bp.post("/categories/<int:category_id>/delete")
@admin_required
def category_delete(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"))
if category["name"] in DEFAULT_CATEGORIES:
flash("Standardkategorien bleiben erhalten. Du kannst sie bei Bedarf pausieren.", "info")
return redirect(url_for("admin.category_settings"))
get_db().execute(
"DELETE FROM household_categories WHERE id = ? AND household_id = ?",
(category_id, g.user["household_id"]),
)
get_db().commit()
flash("Die Kategorie wurde entfernt.", "success")
return redirect(url_for("admin.category_settings"))
+14
View File
@@ -57,6 +57,12 @@ def normalize_login_value(raw: str) -> str:
return raw.strip().lower() return raw.strip().lower()
def validate_password_strength(password: str) -> str | None:
if len(password or "") < 10:
return "Bitte ein etwas längeres Passwort wählen."
return None
def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None: def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None:
if not username: if not username:
return "Bitte einen Benutzernamen eintragen." return "Bitte einen Benutzernamen eintragen."
@@ -140,6 +146,8 @@ def setup():
error = "Bitte ein Passwort vergeben." error = "Bitte ein Passwort vergeben."
elif error is None and password != password_repeat: elif error is None and password != password_repeat:
error = "Die Passwörter stimmen nicht überein." error = "Die Passwörter stimmen nicht überein."
elif error is None:
error = validate_password_strength(password)
if error is None: if error is None:
database.execute( database.execute(
@@ -244,6 +252,8 @@ def change_password():
error = "Bitte ein neues Passwort eintragen." error = "Bitte ein neues Passwort eintragen."
elif new_password != new_password_repeat: elif new_password != new_password_repeat:
error = "Die neuen Passwörter stimmen nicht überein." error = "Die neuen Passwörter stimmen nicht überein."
else:
error = validate_password_strength(new_password)
if error is None: if error is None:
get_db().execute( get_db().execute(
@@ -289,6 +299,10 @@ def validate_admin_user_form(
return "Bitte ein Passwort vergeben." return "Bitte ein Passwort vergeben."
if password and password != password_repeat: if password and password != password_repeat:
return "Die Passwörter stimmen nicht überein." return "Die Passwörter stimmen nicht überein."
if password:
password_error = validate_password_strength(password)
if password_error:
return password_error
if current_user_id == g.user["id"] and not is_active: if current_user_id == g.user["id"] and not is_active:
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren." return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
return None return None
+28 -2
View File
@@ -38,7 +38,8 @@ BUILDER_LABELS = {
"protein": "Proteinquelle", "protein": "Proteinquelle",
"carb": "Kohlenhydratquelle", "carb": "Kohlenhydratquelle",
"veg": "Gemüse / Ballaststoffquelle", "veg": "Gemüse / Ballaststoffquelle",
"nuts": "Nüsse / Samen", "nuts": "Nüsse",
"seeds": "Saaten",
"fruit": "Obst", "fruit": "Obst",
"dairy": "Milchprodukt", "dairy": "Milchprodukt",
"neutral": "Neutral / sonstiges", "neutral": "Neutral / sonstiges",
@@ -48,7 +49,8 @@ BUILDER_DESCRIPTIONS = {
"protein": "Passt eher zu sättigenden Eiweißquellen.", "protein": "Passt eher zu sättigenden Eiweißquellen.",
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.", "carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
"veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.", "veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.",
"nuts": "Passt eher zu Nüssen oder Samen.", "nuts": "Passt eher zu Nüssen.",
"seeds": "Passt eher zu Saaten.",
"fruit": "Passt eher zu Obst.", "fruit": "Passt eher zu Obst.",
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.", "dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
"neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.", "neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.",
@@ -56,6 +58,30 @@ BUILDER_DESCRIPTIONS = {
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()] BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
ENERGY_DENSITY_OPTIONS = [
("low", "Eher leicht"),
("neutral", "Neutral"),
("high", "Eher gehaltvoll"),
]
ENERGY_DENSITY_LABELS = {
"low": "Eher leicht",
"neutral": "Neutral",
"high": "Eher gehaltvoll",
}
SUGGESTION_STYLE_OPTIONS = [
("balanced", "Eher ausgewogen"),
("fitness", "Fitness"),
("protein", "Proteinbetont"),
]
SUGGESTION_STYLE_LABELS = {
"balanced": "Eher ausgewogen",
"fitness": "Fitness",
"protein": "Proteinbetont",
}
WEEKDAY_OPTIONS = [ WEEKDAY_OPTIONS = [
(0, "Montag"), (0, "Montag"),
(1, "Dienstag"), (1, "Dienstag"),
+60 -1
View File
@@ -10,7 +10,7 @@ from werkzeug.security import generate_password_hash
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
CURRENT_SCHEMA_VERSION = "0.6.0" CURRENT_SCHEMA_VERSION = "1.1.1"
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
@@ -18,9 +18,11 @@ def get_db() -> sqlite3.Connection:
g.db = sqlite3.connect( g.db = sqlite3.connect(
current_app.config["DATABASE_PATH"], current_app.config["DATABASE_PATH"],
detect_types=sqlite3.PARSE_DECLTYPES, detect_types=sqlite3.PARSE_DECLTYPES,
timeout=30,
) )
g.db.row_factory = sqlite3.Row g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA foreign_keys = ON") g.db.execute("PRAGMA foreign_keys = ON")
g.db.execute("PRAGMA busy_timeout = 30000")
return g.db return g.db
@@ -127,6 +129,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "items", "owner_user_id INTEGER") add_column_if_missing(database, "items", "owner_user_id INTEGER")
add_column_if_missing(database, "items", "target_user_id INTEGER") add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
if table_exists(database, "shopping_entries"): if table_exists(database, "shopping_entries"):
add_column_if_missing(database, "shopping_entries", "household_id INTEGER") add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
@@ -148,12 +151,17 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
reminders_enabled INTEGER NOT NULL DEFAULT 1, reminders_enabled INTEGER NOT NULL DEFAULT 1,
push_enabled INTEGER NOT NULL DEFAULT 0, push_enabled INTEGER NOT NULL DEFAULT 0,
notification_channel TEXT NOT NULL DEFAULT 'in_app', notification_channel TEXT NOT NULL DEFAULT 'in_app',
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
energy_preference TEXT NOT NULL DEFAULT 'neutral',
remind_before_shopping INTEGER NOT NULL DEFAULT 1, remind_before_shopping INTEGER NOT NULL DEFAULT 1,
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1, show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1, show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1, remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1, remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
suggest_home_for_today INTEGER NOT NULL DEFAULT 1, suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
remind_small_snack INTEGER NOT NULL DEFAULT 0, remind_small_snack INTEGER NOT NULL DEFAULT 0,
remind_nuts INTEGER NOT NULL DEFAULT 0, remind_nuts INTEGER NOT NULL DEFAULT 0,
@@ -184,6 +192,32 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
""" """
) )
database.execute(
"""
CREATE TABLE IF NOT EXISTS reminder_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
event_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, event_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
database.execute(
"""
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
suggestion_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, suggestion_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
database.execute( database.execute(
""" """
CREATE TABLE IF NOT EXISTS shopping_needs ( CREATE TABLE IF NOT EXISTS shopping_needs (
@@ -216,6 +250,13 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "plan_entries", "owner_user_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'") add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
if table_exists(database, "user_settings"):
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
def ensure_default_household(database: sqlite3.Connection) -> int: def ensure_default_household(database: sqlite3.Connection) -> int:
household = database.execute( household = database.execute(
@@ -332,8 +373,14 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, table_name, "owner_user_id INTEGER") add_column_if_missing(database, table_name, "owner_user_id INTEGER")
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "items", "target_user_id INTEGER") add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
if default_owner_id is not None: if default_owner_id is not None:
database.execute( database.execute(
@@ -378,6 +425,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
SELECT id FROM users SELECT id FROM users
""" """
) )
database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")
database.execute( database.execute(
""" """
@@ -416,6 +469,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
ON shopping_needs (household_id, activation_date, is_activated) ON shopping_needs (household_id, activation_date, is_activated)
""" """
) )
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_hidden_generated_suggestions_user
ON hidden_generated_suggestions (user_id)
"""
)
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION) set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
+430 -50
View File
@@ -4,6 +4,7 @@ from collections import defaultdict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from itertools import product from itertools import product
from pathlib import Path from pathlib import Path
import sqlite3
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -27,10 +28,14 @@ from .constants import (
DEFAULT_CATEGORY_BUILDERS, DEFAULT_CATEGORY_BUILDERS,
DAY_TEMPLATE_NAME_SUGGESTIONS, DAY_TEMPLATE_NAME_SUGGESTIONS,
DEFAULT_CATEGORIES, DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS,
ITEM_KIND_LABELS, ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS, ITEM_KIND_SINGULAR_LABELS,
ITEM_SET_NAME_SUGGESTIONS, ITEM_SET_NAME_SUGGESTIONS,
NOTIFICATION_CHANNEL_OPTIONS, NOTIFICATION_CHANNEL_OPTIONS,
SUGGESTION_STYLE_LABELS,
SUGGESTION_STYLE_OPTIONS,
VISIBILITY_DESCRIPTIONS, VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS, VISIBILITY_LABELS,
WEEKDAY_OPTIONS, WEEKDAY_OPTIONS,
@@ -75,8 +80,10 @@ def refresh_due_context():
if getattr(g, "user", None) is None: if getattr(g, "user", None) is None:
return None return None
if request.method == "GET" and endpoint.startswith("main."): if request.method == "GET" and endpoint.startswith("main."):
ensure_user_settings_row() try:
activate_due_shopping_needs() activate_due_shopping_needs()
except sqlite3.OperationalError:
current_app.logger.warning("Due shopping needs could not be activated during this request.")
return None return None
@@ -168,19 +175,54 @@ def get_household_settings() -> dict:
} }
def ensure_user_settings_row() -> None: def default_user_settings() -> dict:
suggestion_style = "balanced"
return {
"user_id": int(g.user["id"]),
"reminders_enabled": True,
"push_enabled": False,
"notification_channel": "in_app",
"suggestion_style": suggestion_style,
"energy_preference": suggestion_style_energy_preference(suggestion_style),
"remind_before_shopping": True,
"remind_on_shopping_day": True,
"show_missing_for_upcoming_week": True,
"show_planned_not_shopped": True,
"remind_tomorrow_if_sparse": True,
"remind_week_if_sparse": True,
"push_missing_breakfast": False,
"push_missing_lunch": False,
"push_missing_dinner": False,
"suggest_home_for_today": True,
"remind_small_snack": False,
"remind_nuts": False,
"show_meal_balancing": True,
"suggest_templates": True,
"suggest_patterns": True,
}
def ensure_user_settings_row(*, commit: bool = False) -> None:
existing = get_db().execute(
"SELECT 1 FROM user_settings WHERE user_id = ? LIMIT 1",
(g.user["id"],),
).fetchone()
if existing is not None:
return
get_db().execute( get_db().execute(
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", "INSERT INTO user_settings (user_id) VALUES (?)",
(g.user["id"],), (g.user["id"],),
) )
if commit:
get_db().commit()
def get_user_settings() -> dict: def get_user_settings() -> dict:
ensure_user_settings_row() settings = default_user_settings()
row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone() row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone()
if row is None: if row is None:
return {} return settings
settings = dict(row) settings.update(dict(row))
boolean_fields = { boolean_fields = {
"reminders_enabled", "reminders_enabled",
"push_enabled", "push_enabled",
@@ -190,6 +232,9 @@ def get_user_settings() -> dict:
"show_planned_not_shopped", "show_planned_not_shopped",
"remind_tomorrow_if_sparse", "remind_tomorrow_if_sparse",
"remind_week_if_sparse", "remind_week_if_sparse",
"push_missing_breakfast",
"push_missing_lunch",
"push_missing_dinner",
"suggest_home_for_today", "suggest_home_for_today",
"remind_small_snack", "remind_small_snack",
"remind_nuts", "remind_nuts",
@@ -200,6 +245,8 @@ def get_user_settings() -> dict:
for field in boolean_fields: for field in boolean_fields:
settings[field] = bool(settings.get(field)) settings[field] = bool(settings.get(field))
settings["notification_channel"] = settings.get("notification_channel") or "in_app" settings["notification_channel"] = settings.get("notification_channel") or "in_app"
settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced")
settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"])
return settings return settings
@@ -220,6 +267,24 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") ->
return raw if raw in allowed else default return raw if raw in allowed else default
def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str:
allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS}
if raw == "easy" or raw == "snack":
return "balanced"
return raw if raw in allowed else default
def normalize_energy_density(raw: str | None, default: str = "neutral") -> str:
allowed = {value for value, _label in ENERGY_DENSITY_OPTIONS}
return raw if raw in allowed else default
def suggestion_style_energy_preference(style: str) -> str:
if style == "fitness":
return "low"
return "neutral"
def visible_clause(table_alias: str) -> str: def visible_clause(table_alias: str) -> str:
return ( return (
f"{table_alias}.household_id = ? " f"{table_alias}.household_id = ? "
@@ -284,13 +349,20 @@ def describe_record(entry: dict) -> dict:
target_name = user_display_name(entry.get("target_display_name"), entry.get("target_username")) if entry.get("target_user_id") else None 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["owner_name"] = owner_name
entry["target_name"] = target_name entry["target_name"] = target_name
entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral")
entry["energy_density_label"] = ENERGY_DENSITY_LABELS.get(entry["energy_density"], ENERGY_DENSITY_LABELS["neutral"])
entry["is_personal"] = entry.get("visibility") == "personal" entry["is_personal"] = entry.get("visibility") == "personal"
entry["is_shared"] = entry.get("visibility") == "shared" entry["is_shared"] = entry.get("visibility") == "shared"
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"] entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam") entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam")
entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "") entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "")
entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}" entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}"
entry["for_label"] = f"Für {target_name}" if target_name else "Für alle" if target_name:
entry["for_label"] = f"Für {target_name}"
elif entry["is_personal"]:
entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}"
else:
entry["for_label"] = "Für alle"
entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin" entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
return entry return entry
@@ -501,6 +573,44 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
return builder_map return builder_map
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
builder_keys = {key for item in component_items for key in item.get("builder_keys", ["neutral"])}
energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items]
score = 0
style = settings.get("suggestion_style", "balanced")
if style == "fitness":
score += 9 if "protein" in builder_keys else 0
score += 4 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0
score += 2 if "carb" in builder_keys else 0
elif style == "protein":
score += 8 if "protein" in builder_keys else 0
score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0
else:
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
score += 5 if "carb" in builder_keys else 0
score += 4 if builder_keys & {"dairy", "fruit", "nuts", "seeds"} else 0
else:
score += 5 if "protein" in builder_keys else 0
score += 4 if "carb" in builder_keys else 0
score += 4 if "veg" in builder_keys else 0
energy_preference = settings.get("energy_preference", "neutral")
if style == "fitness":
score += energy_values.count("low") * 4
score -= energy_values.count("high") * 2
elif energy_preference == "high":
score += energy_values.count("high") * 3
score -= energy_values.count("low")
elif energy_preference == "low":
score += energy_values.count("low") * 3
score -= energy_values.count("high")
else:
score += energy_values.count("neutral")
return score
def fetch_items( def fetch_items(
*, *,
kind: str | None = None, kind: str | None = None,
@@ -589,6 +699,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
{ {
"name": request.form.get("name", "").strip(), "name": request.form.get("name", "").strip(),
"category": request.form.get("category", "").strip(), "category": request.form.get("category", "").strip(),
"energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")),
"note": request.form.get("note", "").strip(), "note": request.form.get("note", "").strip(),
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")), "visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
"target_user_id": normalize_target_user_id(request.form.get("target_user_id")), "target_user_id": normalize_target_user_id(request.form.get("target_user_id")),
@@ -598,6 +709,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()], "component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
"quick_food_name": request.form.get("quick_food_name", "").strip(), "quick_food_name": request.form.get("quick_food_name", "").strip(),
"quick_food_category": request.form.get("quick_food_category", "").strip(), "quick_food_category": request.form.get("quick_food_category", "").strip(),
"quick_food_energy_density": normalize_energy_density(request.form.get("quick_food_energy_density"), form_data.get("quick_food_energy_density", "neutral")),
"quick_food_note": request.form.get("quick_food_note", "").strip(), "quick_food_note": request.form.get("quick_food_note", "").strip(),
} }
) )
@@ -608,9 +720,9 @@ def create_quick_food_from_form(form_data: dict) -> int:
cursor = get_db().execute( cursor = get_db().execute(
""" """
INSERT INTO items ( INSERT INTO items (
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, created_by, updated_by household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, created_by, updated_by
) )
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?)
""", """,
( (
current_household_id(), current_household_id(),
@@ -619,6 +731,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
form_data["visibility"], form_data["visibility"],
form_data["quick_food_name"], form_data["quick_food_name"],
form_data["quick_food_category"], form_data["quick_food_category"],
form_data["quick_food_energy_density"],
form_data["quick_food_note"], form_data["quick_food_note"],
g.user["id"], g.user["id"],
g.user["id"], g.user["id"],
@@ -1159,6 +1272,23 @@ def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]:
return tuple(sorted({int(component_id) for component_id in component_ids})) return tuple(sorted({int(component_id) for component_id in component_ids}))
def generated_suggestion_key(component_ids: list[int]) -> str:
signature = normalized_component_signature(component_ids)
return "generated:" + "-".join(str(component_id) for component_id in signature)
def fetch_hidden_generated_suggestion_keys() -> set[str]:
rows = get_db().execute(
"""
SELECT suggestion_key
FROM hidden_generated_suggestions
WHERE user_id = ?
""",
(g.user["id"],),
).fetchall()
return {row["suggestion_key"] for row in rows}
def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str: def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
names = [item["name"] for item in combo] names = [item["name"] for item in combo]
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2: if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2:
@@ -1169,95 +1299,184 @@ def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
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)
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
target_patterns = [ target_patterns = [
("carb", "dairy", "fruit"), {
("carb", "dairy", "nuts"), "slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
("carb", "fruit", "dairy"), "reason": "Passt gut zu Frühstück oder Snack",
},
{
"slots": ({"carb"}, {"dairy", "protein"}),
"reason": "Zuhause schnell kombinierbar",
},
{
"slots": ({"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
"reason": "Lässt sich gut als kleiner Snack vormerken",
},
] ]
reasons = {
("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack",
("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken",
("carb", "fruit", "dairy"): "Zuhause gut kombinierbar",
}
else: else:
target_patterns = [ target_patterns = [
("protein", "carb", "veg"), {
("protein", "carb"), "slots": ({"protein"}, {"carb"}, {"veg"}),
"reason": "Zuhause als vollständige Mahlzeit möglich",
},
{
"slots": ({"protein"}, {"carb"}),
"reason": "Lässt sich leicht ergänzen",
},
{
"slots": ({"protein"}, {"veg"}),
"reason": "Zuhause schon gut kombinierbar",
},
{
"slots": ({"carb"}, {"veg"}),
"reason": "Daraus kann schnell etwas Einfaches werden",
},
] ]
reasons = {
("protein", "carb", "veg"): "Zuhause als vollständige Mahlzeit möglich",
("protein", "carb"): "Lässt sich leicht ergänzen",
}
suggestions: list[dict] = [] suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set() seen_signatures: set[tuple[int, ...]] = set()
def slot_matches(food: dict, slot_keys: set[str]) -> bool:
return bool(slot_keys & set(food.get("builder_keys", ["neutral"])))
for pattern in target_patterns: for pattern in target_patterns:
groups = [builder_groups.get(builder_key, []) for builder_key in pattern] slot_candidates = []
if any(not group for group in groups): for slot_keys in pattern["slots"]:
matches = [food for food in home_foods if slot_matches(food, slot_keys)]
if not matches:
slot_candidates = []
break
slot_candidates.append(matches)
if not slot_candidates:
continue continue
for combo in product(*groups):
for combo in product(*slot_candidates):
signature = normalized_component_signature([item["id"] for item in combo]) signature = normalized_component_signature([item["id"] for item in combo])
if len(signature) != len(pattern) or signature in seen_signatures: if len(signature) != len(combo) or signature in seen_signatures:
continue continue
seen_signatures.add(signature) seen_signatures.add(signature)
combo_items = list(combo) combo_items = list(combo)
suggestions.append( suggestions.append(
{ {
"title": build_generated_meal_name(combo_items, daypart_slug), "title": build_generated_meal_name(combo_items, daypart_slug),
"reason": reasons.get(pattern, "Zuhause gut kombinierbar"), "reason": pattern["reason"],
"component_ids": [item["id"] for item in combo_items], "component_ids": [item["id"] for item in combo_items],
"existing_item_id": None, "existing_item_id": None,
"visibility": "shared",
"daypart_id": None,
"missing_component_ids": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": True,
"suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]),
} }
) )
if len(suggestions) >= limit: if len(suggestions) >= limit * 3:
return suggestions break
if len(suggestions) >= limit * 3:
break
return suggestions return suggestions
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
settings = get_user_settings()
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
hidden_keys = fetch_hidden_generated_suggestion_keys()
home_foods = [ home_foods = [
item item
for item in fetch_items(kind="food", availability="home") for item in fetch_items(kind="food", availability="home")
if item_matches_daypart(item, daypart_id) if item_matches_daypart(item, daypart_id)
] ]
home_food_ids = {item["id"] for item in home_foods} home_food_ids = {item["id"] for item in home_foods}
home_food_map = {int(item["id"]): item for item in home_foods}
visible_foods = [
item
for item in fetch_items(kind="food", include_archived=False)
if item_matches_daypart(item, daypart_id)
]
visible_food_map = {int(item["id"]): item for item in visible_foods}
suggestions: list[dict] = [] suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set() seen_signatures: set[tuple[int, ...]] = set()
meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)] meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)]
for meal in meals: for meal in meals:
if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]): if not meal["component_ids"]:
signature = normalized_component_signature(meal["component_ids"]) continue
if signature in seen_signatures: component_ids = [int(component_id) for component_id in meal["component_ids"]]
continue if not all(component_id in visible_food_map for component_id in component_ids):
seen_signatures.add(signature) continue
signature = normalized_component_signature(component_ids)
if signature in seen_signatures:
continue
component_items = [visible_food_map[component_id] for component_id in component_ids]
available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map]
missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids]
if not available_items:
continue
if missing_items and len(missing_items) > 2:
continue
seen_signatures.add(signature)
if missing_items:
missing_names = [item["name"] for item in missing_items]
suggestions.append(
{
"title": meal["name"],
"reason": f"Es fehlt noch: {', '.join(missing_names)}",
"component_ids": component_ids,
"existing_item_id": meal["id"],
"visibility": meal["visibility"],
"daypart_id": daypart_id or meal.get("primary_daypart_id"),
"missing_component_ids": [item["id"] for item in missing_items],
"missing_components": missing_names,
"needs_shopping": True,
"is_generated": False,
"suggestion_key": None,
"score": score_suggestion_components(available_items, daypart_slug=daypart_slug, settings=settings) + 18 - (len(missing_items) * 4),
}
)
else:
suggestions.append( suggestions.append(
{ {
"title": meal["name"], "title": meal["name"],
"reason": "Zuhause vorhanden", "reason": "Zuhause vorhanden",
"component_ids": meal["component_ids"], "component_ids": component_ids,
"existing_item_id": meal["id"], "existing_item_id": meal["id"],
"visibility": meal["visibility"],
"daypart_id": daypart_id or meal.get("primary_daypart_id"),
"missing_component_ids": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": False,
"suggestion_key": None,
"score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40,
} }
) )
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2): for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2):
signature = normalized_component_signature(suggestion["component_ids"]) signature = normalized_component_signature(suggestion["component_ids"])
if signature in seen_signatures: if signature in seen_signatures or suggestion["suggestion_key"] in hidden_keys:
continue continue
seen_signatures.add(signature) seen_signatures.add(signature)
component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map]
suggestion["score"] = score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings)
suggestions.append(suggestion) suggestions.append(suggestion)
deduped: list[dict] = [] deduped: list[dict] = []
seen = set() seen = set()
for suggestion in suggestions: ranked_suggestions = sorted(
suggestions,
key=lambda suggestion: (
-int(suggestion.get("score", 0)),
0 if suggestion.get("existing_item_id") else 1,
suggestion["title"].lower(),
),
)
for suggestion in ranked_suggestions:
if suggestion["title"] in seen: if suggestion["title"] in seen:
continue continue
seen.add(suggestion["title"]) seen.add(suggestion["title"])
@@ -1287,6 +1506,10 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id) item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
if first_missing in item.get("builder_keys", []) if first_missing in item.get("builder_keys", [])
] ]
home_matches = sorted(
home_matches,
key=lambda item: -score_suggestion_components([item], daypart["slug"], settings),
)
text_map = { text_map = {
"protein": "Dazu könnte noch eine Proteinquelle gut passen.", "protein": "Dazu könnte noch eine Proteinquelle gut passen.",
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.", "carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.",
@@ -1313,6 +1536,13 @@ def build_daypart_suggestions(daypart_id: int) -> list[dict]:
"reason": "Für später vormerken", "reason": "Für später vormerken",
"component_ids": [], "component_ids": [],
"existing_item_id": item["id"] if item["kind"] == "meal" else None, "existing_item_id": item["id"] if item["kind"] == "meal" else None,
"visibility": item["visibility"],
"daypart_id": daypart_id,
"missing_component_ids": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": False,
"suggestion_key": None,
} }
for item in archived_items[:2] for item in archived_items[:2]
] ]
@@ -1362,7 +1592,7 @@ def build_dashboard_hints(today: date) -> list[str]:
hints.append("Für den nächsten Einkauf sind schon ein paar Dinge vorgemerkt.") hints.append("Für den nächsten Einkauf sind schon ein paar Dinge vorgemerkt.")
if settings.get("remind_nuts"): if settings.get("remind_nuts"):
nut_items = [item for item in fetch_items(kind="food", availability="home") if "nuts" in item.get("builder_keys", [])] nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))]
if nut_items: if nut_items:
hints.append("Heute schon an Nüsse gedacht?") hints.append("Heute schon an Nüsse gedacht?")
@@ -1567,7 +1797,52 @@ def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]:
return result return result
def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None): def build_selected_quick_action(
*,
daypart_id: int,
selected_item_id: int | None,
selected_meal_name: str,
selected_component_ids: list[int],
candidates: list[dict],
) -> dict | None:
if selected_item_id:
selected_item = next((item for item in candidates if int(item["id"]) == int(selected_item_id)), None)
if selected_item is None:
try:
selected_item = get_item(selected_item_id)
except ValueError:
selected_item = None
if selected_item is not None:
return {
"type": "existing",
"title": selected_item["name"],
"subtitle": "Bereit zum Eintragen",
"item_id": int(selected_item["id"]),
"visibility": selected_item["visibility"],
"daypart_id": daypart_id,
}
if selected_meal_name and selected_component_ids:
return {
"type": "generated",
"title": selected_meal_name,
"subtitle": "Vorgeschlagen aus dem, was zuhause da ist",
"component_ids": selected_component_ids,
"visibility": "shared",
"daypart_id": daypart_id,
}
return None
def build_day_planner_sections(
selected_date: date,
selected_item_id: int | None,
selected_daypart_id: int | None,
selected_meal_name: str = "",
selected_component_ids: list[int] | None = None,
):
selected_component_ids = selected_component_ids or []
sections = [] sections = []
day_entries = fetch_day_plan_entries(selected_date) day_entries = fetch_day_plan_entries(selected_date)
for daypart in get_dayparts(): for daypart in get_dayparts():
@@ -1584,6 +1859,7 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
+ [item for item in candidates if item["kind"] == "food"], + [item for item in candidates if item["kind"] == "food"],
limit=20, limit=20,
) )
search_candidates = dedupe_items(meal_candidates + food_candidates, limit=24)
entry_item_ids = [int(entry["item_id"]) for entry in entries] entry_item_ids = [int(entry["item_id"]) for entry in entries]
sections.append( sections.append(
{ {
@@ -1592,10 +1868,18 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
"candidates": candidates, "candidates": candidates,
"meal_candidates": meal_candidates, "meal_candidates": meal_candidates,
"food_candidates": food_candidates, "food_candidates": food_candidates,
"search_candidates": search_candidates,
"recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3), "recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3),
"suggestions": build_daypart_suggestions(daypart["id"]), "suggestions": build_daypart_suggestions(daypart["id"]),
"balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids), "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, "selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None,
"selected_quick_action": build_selected_quick_action(
daypart_id=int(daypart["id"]),
selected_item_id=selected_item_id if selected_daypart_id == daypart["id"] else None,
selected_meal_name=selected_meal_name if selected_daypart_id == daypart["id"] else "",
selected_component_ids=selected_component_ids if selected_daypart_id == daypart["id"] else [],
candidates=candidates,
),
"is_open": selected_daypart_id == daypart["id"], "is_open": selected_daypart_id == daypart["id"],
"summary_items": [entry["item_name"] for entry in entries][:2], "summary_items": [entry["item_name"] for entry in entries][:2],
"default_visibility": "shared", "default_visibility": "shared",
@@ -2082,6 +2366,7 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict):
form_data.get("category") or form_data.get("quick_food_category") form_data.get("category") or form_data.get("quick_food_category")
), ),
form_data=form_data, form_data=form_data,
energy_density_options=ENERGY_DENSITY_OPTIONS,
visibility_options=VISIBILITY_FORM_OPTIONS, visibility_options=VISIBILITY_FORM_OPTIONS,
target_user_options=get_target_user_options(), target_user_options=get_target_user_options(),
) )
@@ -2195,6 +2480,23 @@ def insert_plan_entry(*, item_id: int, daypart_id: int, plan_date: date, visibil
) )
def update_plan_entry(entry_id: int, *, visibility: str, note: str) -> None:
get_db().execute(
"""
UPDATE plan_entries
SET visibility = ?,
owner_user_id = CASE
WHEN ? = 'personal' THEN ?
ELSE owner_user_id
END,
note = ?
WHERE id = ?
""",
(visibility, visibility, g.user["id"], note, entry_id),
)
get_db().commit()
def planner_template_options(): def planner_template_options():
return fetch_day_templates() return fetch_day_templates()
@@ -2255,6 +2557,25 @@ def template_library():
) )
@main_bp.post("/suggestions/hide")
@login_required
def suggestion_hide():
component_ids = [int(value) for value in request.form.getlist("component_ids") if value.isdigit()]
if not component_ids:
flash("Diese Kombination konnte gerade nicht ausgeblendet werden.", "error")
return redirect(request.referrer or url_for("main.dashboard"))
get_db().execute(
"""
INSERT OR IGNORE INTO hidden_generated_suggestions (user_id, suggestion_key)
VALUES (?, ?)
""",
(g.user["id"], generated_suggestion_key(component_ids)),
)
get_db().commit()
flash("Diese generierte Mahlzeit wird dir künftig nicht mehr vorgeschlagen.", "info")
return redirect(request.referrer or url_for("main.dashboard"))
@main_bp.route("/templates/day/new", methods=("GET", "POST")) @main_bp.route("/templates/day/new", methods=("GET", "POST"))
@login_required @login_required
def day_template_create(): def day_template_create():
@@ -2588,18 +2909,24 @@ def settings_view():
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success") flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
elif form_name == "reminders": elif form_name == "reminders":
ensure_user_settings_row() ensure_user_settings_row()
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
get_db().execute( get_db().execute(
""" """
UPDATE user_settings UPDATE user_settings
SET reminders_enabled = ?, SET reminders_enabled = ?,
push_enabled = ?, push_enabled = ?,
notification_channel = ?, notification_channel = ?,
suggestion_style = ?,
energy_preference = ?,
remind_before_shopping = ?, remind_before_shopping = ?,
remind_on_shopping_day = ?, remind_on_shopping_day = ?,
show_missing_for_upcoming_week = ?, show_missing_for_upcoming_week = ?,
show_planned_not_shopped = ?, show_planned_not_shopped = ?,
remind_tomorrow_if_sparse = ?, remind_tomorrow_if_sparse = ?,
remind_week_if_sparse = ?, remind_week_if_sparse = ?,
push_missing_breakfast = ?,
push_missing_lunch = ?,
push_missing_dinner = ?,
suggest_home_for_today = ?, suggest_home_for_today = ?,
remind_small_snack = ?, remind_small_snack = ?,
remind_nuts = ?, remind_nuts = ?,
@@ -2613,12 +2940,17 @@ def settings_view():
parse_checkbox("reminders_enabled", True), parse_checkbox("reminders_enabled", True),
parse_checkbox("push_enabled", False), parse_checkbox("push_enabled", False),
normalize_notification_channel(request.form.get("notification_channel"), "in_app"), normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
suggestion_style,
suggestion_style_energy_preference(suggestion_style),
parse_checkbox("remind_before_shopping", True), parse_checkbox("remind_before_shopping", True),
parse_checkbox("remind_on_shopping_day", True), parse_checkbox("remind_on_shopping_day", True),
parse_checkbox("show_missing_for_upcoming_week", True), parse_checkbox("show_missing_for_upcoming_week", True),
parse_checkbox("show_planned_not_shopped", True), parse_checkbox("show_planned_not_shopped", True),
parse_checkbox("remind_tomorrow_if_sparse", True), parse_checkbox("remind_tomorrow_if_sparse", True),
parse_checkbox("remind_week_if_sparse", True), parse_checkbox("remind_week_if_sparse", True),
parse_checkbox("push_missing_breakfast", False),
parse_checkbox("push_missing_lunch", False),
parse_checkbox("push_missing_dinner", False),
parse_checkbox("suggest_home_for_today", True), parse_checkbox("suggest_home_for_today", True),
parse_checkbox("remind_small_snack", False), parse_checkbox("remind_small_snack", False),
parse_checkbox("remind_nuts", False), parse_checkbox("remind_nuts", False),
@@ -2831,6 +3163,7 @@ def item_create(kind: str):
form_data = { form_data = {
"name": request.args.get("name", "").strip(), "name": request.args.get("name", "").strip(),
"category": "", "category": "",
"energy_density": "neutral",
"note": "", "note": "",
"visibility": "shared", "visibility": "shared",
"target_user_id": None, "target_user_id": None,
@@ -2840,6 +3173,7 @@ def item_create(kind: str):
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()], "component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
"quick_food_name": "", "quick_food_name": "",
"quick_food_category": "", "quick_food_category": "",
"quick_food_energy_density": "neutral",
"quick_food_note": "", "quick_food_note": "",
} }
@@ -2878,9 +3212,9 @@ def item_create(kind: str):
cursor = get_db().execute( cursor = get_db().execute(
""" """
INSERT INTO items ( INSERT INTO items (
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, photo_filename, created_by, updated_by household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, photo_filename, created_by, updated_by
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
current_household_id(), current_household_id(),
@@ -2890,6 +3224,7 @@ def item_create(kind: str):
kind, kind,
form_data["name"], form_data["name"],
form_data["category"], form_data["category"],
form_data["energy_density"],
form_data["note"], form_data["note"],
photo_filename, photo_filename,
g.user["id"], g.user["id"],
@@ -2921,6 +3256,7 @@ def item_edit(item_id: int):
form_data = { form_data = {
"name": item["name"], "name": item["name"],
"category": item["category"] or "", "category": item["category"] or "",
"energy_density": item.get("energy_density") or "neutral",
"note": item["note"] or "", "note": item["note"] or "",
"visibility": item["visibility"], "visibility": item["visibility"],
"target_user_id": item["target_user_id"], "target_user_id": item["target_user_id"],
@@ -2930,6 +3266,7 @@ def item_edit(item_id: int):
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [], "component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
"quick_food_name": "", "quick_food_name": "",
"quick_food_category": "", "quick_food_category": "",
"quick_food_energy_density": "neutral",
"quick_food_note": "", "quick_food_note": "",
} }
@@ -2970,6 +3307,7 @@ def item_edit(item_id: int):
UPDATE items UPDATE items
SET name = ?, SET name = ?,
category = ?, category = ?,
energy_density = ?,
note = ?, note = ?,
visibility = ?, visibility = ?,
target_user_id = ?, target_user_id = ?,
@@ -2981,6 +3319,7 @@ def item_edit(item_id: int):
( (
form_data["name"], form_data["name"],
form_data["category"], form_data["category"],
form_data["energy_density"],
form_data["note"], form_data["note"],
form_data["visibility"], form_data["visibility"],
form_data["target_user_id"], form_data["target_user_id"],
@@ -3303,14 +3642,23 @@ def planner_day():
selected_item_raw = request.args.get("item_id", "").strip() selected_item_raw = request.args.get("item_id", "").strip()
selected_daypart_raw = request.args.get("daypart_id", "").strip() selected_daypart_raw = request.args.get("daypart_id", "").strip()
selected_meal_name = request.args.get("meal_name", "").strip()
selected_components_raw = request.args.get("component_ids", "").strip()
selected_item_id = int(selected_item_raw) if selected_item_raw.isdigit() else None 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 selected_daypart_id = int(selected_daypart_raw) if selected_daypart_raw.isdigit() else None
selected_component_ids = [int(value) for value in selected_components_raw.split(",") if value.isdigit()]
return render_template( return render_template(
"planner/day.html", "planner/day.html",
selected_date=selected_date, selected_date=selected_date,
previous_day=selected_date - timedelta(days=1), previous_day=selected_date - timedelta(days=1),
next_day=selected_date + timedelta(days=1), next_day=selected_date + timedelta(days=1),
sections=build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id), sections=build_day_planner_sections(
selected_date,
selected_item_id,
selected_daypart_id,
selected_meal_name=selected_meal_name,
selected_component_ids=selected_component_ids,
),
today=date.today(), today=date.today(),
visibility_options=VISIBILITY_FORM_OPTIONS, visibility_options=VISIBILITY_FORM_OPTIONS,
day_templates=fetch_day_templates()[:6], day_templates=fetch_day_templates()[:6],
@@ -3351,6 +3699,38 @@ def planner_generated_meal():
return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}") return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}")
@main_bp.post("/planner/<int:entry_id>/update")
@login_required
def planner_update(entry_id: int):
selected_date = parse_plan_date(request.form.get("plan_date"))
entry = get_db().execute(
f"""
SELECT plan_entries.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM plan_entries
LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id
WHERE plan_entries.id = ? AND {visible_clause('plan_entries')}
""",
[entry_id, *visible_params()],
).fetchone()
if entry is None:
flash("Der Planeintrag wurde nicht gefunden.", "error")
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
try:
ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht bearbeiten.")
except PermissionError as exc:
flash(str(exc), "error")
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
visibility = normalize_visibility(request.form.get("visibility"), entry["visibility"])
note = request.form.get("note", "").strip()
update_plan_entry(entry_id, visibility=visibility, note=note)
flash("Der Planeintrag wurde angepasst.", "success")
return redirect(url_for("main.planner_day", date=selected_date.isoformat(), daypart_id=entry["daypart_id"]))
@main_bp.post("/planner/<int:entry_id>/remove") @main_bp.post("/planner/<int:entry_id>/remove")
@login_required @login_required
def planner_remove(entry_id: int): def planner_remove(entry_id: int):
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import time
from datetime import date, datetime
from urllib.parse import quote
from zoneinfo import ZoneInfo
from flask import current_app, g
from .db import get_db
from .main import build_home_recipe_suggestions, get_user_settings
from .push import send_push_message
MEAL_PUSH_RULES = [
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "end_hour": 12, "label": "Frühstück"},
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "end_hour": 18, "label": "Mittagessen"},
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "end_hour": 24, "label": "Abendessen"},
]
def current_local_time() -> datetime:
timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin")
try:
timezone = ZoneInfo(timezone_name)
except Exception:
timezone = ZoneInfo("Europe/Berlin")
return datetime.now(timezone)
def push_delivery_channel_enabled(settings: dict) -> bool:
return (
settings.get("reminders_enabled")
and settings.get("push_enabled")
and settings.get("notification_channel") in {"push", "both"}
)
def fetch_push_ready_users() -> list:
return get_db().execute(
"""
SELECT users.*
FROM users
JOIN user_settings ON user_settings.user_id = users.id
WHERE users.is_active = 1
AND user_settings.reminders_enabled = 1
AND user_settings.push_enabled = 1
AND user_settings.notification_channel IN ('push', 'both')
ORDER BY users.id
"""
).fetchall()
def fetch_daypart_map() -> dict[str, dict]:
return {
row["slug"]: {"id": int(row["id"]), "name": row["name"]}
for row in get_db().execute("SELECT id, slug, name FROM dayparts").fetchall()
}
def plan_exists_for_daypart(user, *, planned_date: date, daypart_id: int) -> bool:
row = get_db().execute(
"""
SELECT COUNT(*) AS count
FROM plan_entries
WHERE household_id = ?
AND plan_date = ?
AND daypart_id = ?
AND (visibility = 'shared' OR owner_user_id = ?)
""",
(int(user["household_id"]), planned_date.isoformat(), daypart_id, int(user["id"])),
).fetchone()
return bool(int(row["count"] or 0))
def reminder_event_exists(user_id: int, event_key: str) -> bool:
row = get_db().execute(
"SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1",
(user_id, event_key),
).fetchone()
return row is not None
def mark_reminder_event(user_id: int, event_key: str) -> None:
get_db().execute(
"""
INSERT OR IGNORE INTO reminder_events (user_id, event_key)
VALUES (?, ?)
""",
(user_id, event_key),
)
get_db().commit()
def due_for_rule(now: datetime, *, hour: int, minute: int, end_hour: int) -> bool:
current_minutes = (now.hour * 60) + now.minute
target_minutes = (hour * 60) + minute
end_minutes = end_hour * 60
return target_minutes <= current_minutes < end_minutes
def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str:
base = f"/planner/day?date={planned_date.isoformat()}&daypart_id={daypart_id}"
if not suggestion:
return f"{base}#daypart-{daypart_id}"
if suggestion.get("existing_item_id"):
return f"{base}&item_id={int(suggestion['existing_item_id'])}#daypart-{daypart_id}"
component_ids = ",".join(str(component_id) for component_id in suggestion.get("component_ids", []))
if suggestion.get("title") and component_ids:
meal_name = quote(str(suggestion["title"]))
return f"{base}&meal_name={meal_name}&component_ids={component_ids}#daypart-{daypart_id}"
return f"{base}#daypart-{daypart_id}"
def build_push_message(label: str, suggestion: dict | None) -> tuple[str, str]:
title = f"Nouri · {label}"
if suggestion and suggestion.get("title"):
return title, f"Für {label.lower()} ist noch nichts geplant. Möglich wäre gerade: {suggestion['title']}."
return title, f"Für {label.lower()} ist noch nichts geplant."
def best_suggestion_for_user(user, daypart_id: int) -> dict | None:
previous_user = getattr(g, "user", None)
g.user = user
try:
suggestions = build_home_recipe_suggestions(daypart_id, limit=1)
finally:
g.user = previous_user
return suggestions[0] if suggestions else None
def send_due_meal_pushes(now: datetime | None = None) -> int:
now = now or current_local_time()
planned_date = now.date()
sent_count = 0
dayparts = fetch_daypart_map()
for user in fetch_push_ready_users():
g.user = user
settings = get_user_settings()
if not push_delivery_channel_enabled(settings):
continue
subscriptions = get_db().execute(
"""
SELECT endpoint, p256dh, auth
FROM push_subscriptions
WHERE user_id = ? AND is_active = 1
ORDER BY updated_at DESC
""",
(int(user["id"]),),
).fetchall()
if not subscriptions:
continue
for rule in MEAL_PUSH_RULES:
if not settings.get(rule["setting"]):
continue
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"], end_hour=rule["end_hour"]):
continue
daypart = dayparts.get(rule["slug"])
if not daypart:
continue
if plan_exists_for_daypart(user, planned_date=planned_date, daypart_id=daypart["id"]):
continue
event_key = f"meal-push:{planned_date.isoformat()}:{rule['slug']}"
if reminder_event_exists(int(user["id"]), event_key):
continue
suggestion = best_suggestion_for_user(user, daypart["id"])
title, body = build_push_message(rule["label"], suggestion)
url = build_push_target_url(planned_date=planned_date, daypart_id=daypart["id"], suggestion=suggestion)
delivered = False
for subscription in subscriptions:
ok, _error = send_push_message(
{
"endpoint": subscription["endpoint"],
"keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]},
},
title=title,
body=body,
url=url,
)
delivered = delivered or ok
if delivered:
mark_reminder_event(int(user["id"]), event_key)
sent_count += 1
return sent_count
def reminder_worker_loop(sleep_seconds: int = 60) -> None:
while True:
try:
send_due_meal_pushes()
except Exception as exc: # pragma: no cover - background worker fallback
current_app.logger.warning("Reminder worker skipped one cycle: %s", exc)
time.sleep(sleep_seconds)
+24
View File
@@ -50,12 +50,17 @@ CREATE TABLE IF NOT EXISTS user_settings (
reminders_enabled INTEGER NOT NULL DEFAULT 1, reminders_enabled INTEGER NOT NULL DEFAULT 1,
push_enabled INTEGER NOT NULL DEFAULT 0, push_enabled INTEGER NOT NULL DEFAULT 0,
notification_channel TEXT NOT NULL DEFAULT 'in_app', notification_channel TEXT NOT NULL DEFAULT 'in_app',
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
energy_preference TEXT NOT NULL DEFAULT 'neutral',
remind_before_shopping INTEGER NOT NULL DEFAULT 1, remind_before_shopping INTEGER NOT NULL DEFAULT 1,
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1, show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1, show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1, remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1, remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
suggest_home_for_today INTEGER NOT NULL DEFAULT 1, suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
remind_small_snack INTEGER NOT NULL DEFAULT 0, remind_small_snack INTEGER NOT NULL DEFAULT 0,
remind_nuts INTEGER NOT NULL DEFAULT 0, remind_nuts INTEGER NOT NULL DEFAULT 0,
@@ -80,6 +85,24 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS reminder_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
event_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, event_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
suggestion_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, suggestion_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dayparts ( CREATE TABLE IF NOT EXISTS dayparts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
@@ -96,6 +119,7 @@ CREATE TABLE IF NOT EXISTS items (
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')), kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
name TEXT NOT NULL, name TEXT NOT NULL,
category TEXT, category TEXT,
energy_density TEXT NOT NULL DEFAULT 'neutral',
note TEXT, note TEXT,
photo_filename TEXT, photo_filename TEXT,
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')), availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
+79 -10
View File
@@ -89,7 +89,8 @@ textarea {
} }
button, button,
.button { .button,
.ghost-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -260,32 +261,54 @@ h3,
@media (min-width: 1081px) { @media (min-width: 1081px) {
.site-header { .site-header {
grid-template-columns: 1fr; grid-template-columns: auto minmax(0, 1fr);
column-gap: 1.5rem;
row-gap: 0.9rem; row-gap: 0.9rem;
align-items: center;
} }
.desktop-header-main { .desktop-header-main {
display: grid; display: contents;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1.4rem;
} }
.desktop-header-sub { .desktop-header-sub {
display: flex; display: contents;
justify-content: flex-start;
} }
.desktop-nav { .desktop-nav {
width: 100%; grid-column: 2;
grid-row: 1;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center;
overflow-x: auto;
scrollbar-width: none;
}
.desktop-nav::-webkit-scrollbar {
display: none;
} }
.desktop-actions { .desktop-actions {
width: 100%; grid-column: 2;
grid-row: 2;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start; justify-content: flex-start;
align-self: center; align-self: center;
} }
.brand {
grid-column: 1;
grid-row: 1 / span 2;
align-self: center;
}
.nav-link-inner,
.desktop-actions > * {
white-space: nowrap;
}
} }
.user-chip, .user-chip,
@@ -915,6 +938,31 @@ legend {
background: color-mix(in srgb, var(--surface) 88%, #fff 12%); background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
} }
.planner-entry-edit {
margin-top: 0.85rem;
}
.planner-entry-edit > summary {
width: fit-content;
cursor: pointer;
list-style: none;
}
.planner-entry-edit > summary::-webkit-details-marker {
display: none;
}
.planner-entry-inline-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
margin-top: 0.8rem;
}
.planner-entry-inline-form .wide {
grid-column: 1 / -1;
}
.template-card, .template-card,
.template-list-card, .template-list-card,
.suggestion-card { .suggestion-card {
@@ -930,6 +978,23 @@ legend {
gap: 0.9rem; gap: 0.9rem;
} }
.template-list-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
}
.template-list-card-actions form {
margin: 0;
}
.template-list-card .ghost-button,
.template-list-card .button {
width: auto;
align-self: flex-start;
}
.week-template-row { .week-template-row {
padding: 1rem; padding: 1rem;
border-radius: 18px; border-radius: 18px;
@@ -1483,6 +1548,10 @@ legend {
min-width: 100%; min-width: 100%;
} }
.planner-entry-inline-form {
grid-template-columns: 1fr;
}
.mobile-nav-stack { .mobile-nav-stack {
position: fixed; position: fixed;
left: 0.75rem; left: 0.75rem;
+47 -2
View File
@@ -78,8 +78,8 @@
const applyFilter = () => { const applyFilter = () => {
const term = input.value.trim().toLowerCase(); const term = input.value.trim().toLowerCase();
if (!term) { if (!term) {
items.forEach((item) => { items.forEach((item, index) => {
item.hidden = false; item.hidden = hasLimit ? index >= resultLimit : false;
}); });
syncGroups(); syncGroups();
return; return;
@@ -109,8 +109,53 @@
}); });
}; };
const initIosPullToRefresh = () => {
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (!isAppleTouchDevice) return;
let startY = 0;
let maxPull = 0;
let tracking = false;
window.addEventListener("touchstart", (event) => {
if (window.scrollY > 0) {
tracking = false;
return;
}
startY = event.touches[0].clientY;
maxPull = 0;
tracking = true;
}, { passive: true });
window.addEventListener("touchmove", (event) => {
if (!tracking) return;
const currentY = event.touches[0].clientY;
maxPull = Math.max(maxPull, currentY - startY);
}, { passive: true });
window.addEventListener("touchend", () => {
if (tracking && maxPull > 96 && window.scrollY <= 2) {
window.location.reload();
}
tracking = false;
maxPull = 0;
}, { passive: true });
document.addEventListener("gesturestart", (event) => {
event.preventDefault();
});
document.addEventListener("touchmove", (event) => {
if (event.touches.length > 1) {
event.preventDefault();
}
}, { passive: false });
};
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initMobileSheet(); initMobileSheet();
initFilterInputs(); initFilterInputs();
initIosPullToRefresh();
}); });
})(); })();
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = "nouri-v0-6-0"; const CACHE_NAME = "nouri-v1-0-0";
const OFFLINE_URL = "/static/pwa/offline.html"; const OFFLINE_URL = "/static/pwa/offline.html";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
"/static/css/styles.css", "/static/css/styles.css",
+6
View File
@@ -66,6 +66,12 @@
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %} {% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
</button> </button>
</form> </form>
{% if category.name not in default_categories %}
<form method="post" action="{{ url_for('admin.category_delete', category_id=category.id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Löschen</button>
</form>
{% endif %}
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="de" data-theme="auto"> <html lang="de" data-theme="auto">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>{% block title %}Nouri{% endblock %}</title> <title>{% block title %}Nouri{% endblock %}</title>
<meta name="theme-color" content="#de9862"> <meta name="theme-color" content="#de9862">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -91,7 +91,7 @@
<footer class="site-footer"> <footer class="site-footer">
<div class="footer-copy"> <div class="footer-copy">
<span>Version {{ app_version }}</span> <a href="{{ app_release_url }}" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span> <span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
</div> </div>
<div class="footer-copy"> <div class="footer-copy">
+19 -1
View File
@@ -134,8 +134,26 @@
<div> <div>
<strong>{{ suggestion.title }}</strong> <strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small> <small>{{ suggestion.reason }}</small>
{% if suggestion.needs_shopping and suggestion.missing_components %}
<div class="chip-row">
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
</div>
{% endif %}
</div>
<div class="template-list-card-actions">
{% if suggestion.existing_item_id %}
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
{% else %}
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
{{ csrf_input() }}
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
</form>
{% endif %}
</div> </div>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+19 -1
View File
@@ -51,8 +51,26 @@
<div> <div>
<strong>{{ suggestion.title }}</strong> <strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small> <small>{{ suggestion.reason }}</small>
{% if suggestion.needs_shopping and suggestion.missing_components %}
<div class="chip-row">
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
</div>
{% endif %}
</div>
<div class="template-list-card-actions">
{% if suggestion.existing_item_id %}
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
{% else %}
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
{{ csrf_input() }}
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
</form>
{% endif %}
</div> </div>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+18
View File
@@ -55,6 +55,16 @@
</select> </select>
</label> </label>
<label>
Energiedichte
<select name="energy_density">
{% for value, label in energy_density_options %}
<option value="{{ value }}" {% if form_data.energy_density == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
</label>
<label> <label>
Notiz Notiz
<textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea> <textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea>
@@ -149,6 +159,14 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>
Energiedichte
<select name="quick_food_energy_density">
{% for value, label in energy_density_options %}
<option value="{{ value }}" {% if form_data.quick_food_energy_density == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="wide"> <label class="wide">
Notiz Notiz
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional"> <input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
+85 -17
View File
@@ -72,6 +72,35 @@
</summary> </summary>
<div class="day-tile-body"> <div class="day-tile-body">
{% if section.selected_quick_action %}
<div class="suggestion-card">
<strong>{{ section.selected_quick_action.title }}</strong>
<p class="muted">{{ section.selected_quick_action.subtitle }}</p>
{% if section.selected_quick_action.type == 'existing' %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ section.selected_quick_action.item_id }}">
<input type="hidden" name="visibility" value="{{ section.selected_quick_action.visibility }}">
<button class="secondary" type="submit">Jetzt nur noch speichern</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="meal_name" value="{{ section.selected_quick_action.title }}">
<input type="hidden" name="visibility" value="{{ section.selected_quick_action.visibility }}">
{% for component_id in section.selected_quick_action.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="secondary" type="submit">Jetzt nur noch speichern</button>
</form>
{% endif %}
</div>
{% endif %}
{% if section.balance_suggestion %} {% if section.balance_suggestion %}
<div class="suggestion-card"> <div class="suggestion-card">
<strong>{{ section.balance_suggestion.text }}</strong> <strong>{{ section.balance_suggestion.text }}</strong>
@@ -121,20 +150,34 @@
<h3>Passt gut dazu</h3> <h3>Passt gut dazu</h3>
<div class="quick-add-row compact-quick-row"> <div class="quick-add-row compact-quick-row">
{% for suggestion in section.recipe_suggestions %} {% for suggestion in section.recipe_suggestions %}
<form method="post" action="{{ url_for('main.planner_generated_meal') }}"> {% if suggestion.existing_item_id %}
{{ csrf_input() }} <form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}"> {{ csrf_input() }}
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}"> <input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="meal_name" value="{{ suggestion.title }}"> <input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="visibility" value="shared"> <input type="hidden" name="item_id" value="{{ suggestion.existing_item_id }}">
{% for component_id in suggestion.component_ids %} <input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
<input type="hidden" name="component_ids" value="{{ component_id }}"> <button class="quick-add-button compact-button" type="submit">
{% endfor %} <span>{{ suggestion.title }}</span>
<button class="quick-add-button compact-button" type="submit"> <small>{{ suggestion.reason }}</small>
<span>{{ suggestion.title }}</span> </button>
<small>{{ suggestion.reason }}</small> </form>
</button> {% else %}
</form> <form method="post" action="{{ url_for('main.planner_generated_meal') }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
<input type="hidden" name="visibility" value="shared">
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -143,10 +186,10 @@
<div class="planner-subsection"> <div class="planner-subsection">
<label class="planner-search"> <label class="planner-search">
<span>Suche</span> <span>Suche</span>
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}"> <input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}" data-filter-limit="3">
</label> </label>
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}"> <div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
{% for item in section.food_candidates %} {% for item in section.search_candidates %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}"> <form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
{{ csrf_input() }} {{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}"> <input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
@@ -155,7 +198,10 @@
<input type="hidden" name="visibility" value="{{ item.visibility }}"> <input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="picker-row" type="submit"> <button class="picker-row" type="submit">
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
{% if item.availability_state == 'home' %}<small>zuhause</small>{% endif %} <small>
{{ item_kind_labels[item.kind] }}
{% if item.availability_state == 'home' %} · zuhause{% endif %}
</small>
</button> </button>
</form> </form>
{% endfor %} {% endfor %}
@@ -188,6 +234,28 @@
{% if entry.note %} {% if entry.note %}
<p>{{ entry.note }}</p> <p>{{ entry.note }}</p>
{% endif %} {% endif %}
{% if entry.can_edit %}
<details class="planner-entry-edit">
<summary class="ghost-button">Anpassen</summary>
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<label>
Für wen?
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if entry.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="wide">
Notiz
<input type="text" name="note" value="{{ entry.note or '' }}" placeholder="Optional">
</label>
<button type="submit">Speichern</button>
</form>
</details>
{% endif %}
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+16 -1
View File
@@ -55,7 +55,7 @@
<div class="pwa-card"> <div class="pwa-card">
<strong>Push-Mitteilungen</strong> <strong>Push-Mitteilungen</strong>
{% if push_ready %} {% if push_ready %}
<p class="muted">Push ist vorbereitet. Wenn du möchtest, kannst du es auf diesem Gerät freigeben und mit einer Test-Mitteilung prüfen.</p> <p class="muted">Push ist vorbereitet. Wenn du möchtest, kannst du es auf diesem Gerät freigeben, testen und später für Frühstück, Mittagessen oder Abendessen nutzen.</p>
<div class="row-actions"> <div class="row-actions">
<button class="secondary" type="button" data-push-enable>Push erlauben</button> <button class="secondary" type="button" data-push-enable>Push erlauben</button>
<button class="ghost-button" type="button" data-push-disable>Push beenden</button> <button class="ghost-button" type="button" data-push-disable>Push beenden</button>
@@ -98,6 +98,17 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>
Vorschläge eher
<select name="suggestion_style">
{% for value, label in suggestion_style_options %}
<option value="{{ value }}" {% if user_settings.suggestion_style == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">
Ausgewogen bleibt offen. Fitness denkt stärker in proteinbetont und eher leicht. Proteinbetont priorisiert Eiweiß, ohne extra leicht zu werden.
</small>
</label>
</fieldset> </fieldset>
<fieldset> <fieldset>
@@ -112,8 +123,12 @@
<legend>Planung</legend> <legend>Planung</legend>
<label class="inline-check"><input type="checkbox" name="remind_tomorrow_if_sparse" value="1" {% if user_settings.remind_tomorrow_if_sparse %}checked{% endif %}><span>Für morgen erinnern, wenn noch wenig geplant ist</span></label> <label class="inline-check"><input type="checkbox" name="remind_tomorrow_if_sparse" value="1" {% if user_settings.remind_tomorrow_if_sparse %}checked{% endif %}><span>Für morgen erinnern, wenn noch wenig geplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="remind_week_if_sparse" value="1" {% if user_settings.remind_week_if_sparse %}checked{% endif %}><span>Für die Woche erinnern, wenn noch wenig eingeplant ist</span></label> <label class="inline-check"><input type="checkbox" name="remind_week_if_sparse" value="1" {% if user_settings.remind_week_if_sparse %}checked{% endif %}><span>Für die Woche erinnern, wenn noch wenig eingeplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="push_missing_breakfast" value="1" {% if user_settings.push_missing_breakfast %}checked{% endif %}><span>Um 8:00 erinnern, wenn noch kein Frühstück geplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="push_missing_lunch" value="1" {% if user_settings.push_missing_lunch %}checked{% endif %}><span>Um 12:00 erinnern, wenn noch kein Mittagessen geplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="push_missing_dinner" value="1" {% if user_settings.push_missing_dinner %}checked{% endif %}><span>Um 18:00 erinnern, wenn noch kein Abendessen geplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_home_for_today" value="1" {% if user_settings.suggest_home_for_today %}checked{% endif %}><span>Passende Dinge aus Zuhause vorschlagen</span></label> <label class="inline-check"><input type="checkbox" name="suggest_home_for_today" value="1" {% if user_settings.suggest_home_for_today %}checked{% endif %}><span>Passende Dinge aus Zuhause vorschlagen</span></label>
<label class="inline-check"><input type="checkbox" name="show_meal_balancing" value="1" {% if user_settings.show_meal_balancing %}checked{% endif %}><span>Zum Abrunden von Mahlzeiten kleine Vorschläge zeigen</span></label> <label class="inline-check"><input type="checkbox" name="show_meal_balancing" value="1" {% if user_settings.show_meal_balancing %}checked{% endif %}><span>Zum Abrunden von Mahlzeiten kleine Vorschläge zeigen</span></label>
<small class="helper-text">Für Push nimmt Nouri zuerst vorhandene Mahlzeitenideen. Wenn nichts passt, kommt eine Kombination aus dem, was zuhause da ist.</small>
</fieldset> </fieldset>
<fieldset> <fieldset>
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from nouri import create_app
from nouri.reminders import reminder_worker_loop, send_due_meal_pushes
def main() -> None:
parser = argparse.ArgumentParser(description="Run Nouri meal reminder pushes.")
parser.add_argument("--once", action="store_true", help="Run one reminder cycle and exit.")
parser.add_argument("--sleep", type=int, default=60, help="Seconds between reminder cycles.")
args = parser.parse_args()
app = create_app()
with app.app_context():
if args.once:
send_due_meal_pushes()
return
reminder_worker_loop(sleep_seconds=max(15, args.sleep))
if __name__ == "__main__":
main()
+8 -1
View File
@@ -13,4 +13,11 @@ if [ -d /app/bootstrap-data/uploads ] && [ -z "$(ls -A /app/data/uploads 2>/dev/
cp -a /app/bootstrap-data/uploads/. /app/data/uploads/ cp -a /app/bootstrap-data/uploads/. /app/data/uploads/
fi fi
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 wsgi:app if [ "${NOURI_RUN_REMINDER_WORKER:-1}" = "1" ]; then
(
sleep "${NOURI_REMINDER_START_DELAY:-25}"
python /app/code/scripts/reminder_worker.py --sleep 60
) &
fi
exec gunicorn --bind 0.0.0.0:8000 --workers 1 --threads 4 wsgi:app