6 Commits

24 changed files with 1333 additions and 109 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen",
"version": "0.6.0",
"upstreamVersion": "0.6.0",
"version": "1.1.1",
"upstreamVersion": "1.1.1",
"healthCheckPath": "/",
"httpPort": 8000,
"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.
## Merkmale in Version 0.5
## Merkmale in Version 1.0.0
- Lebensmittel und Mahlzeitenideen anlegen
- 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
- globale Kategorien pro Haushalt
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten
- Mobile-Mehr-Menü als Sheet statt eigener Seite
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
- ausgewogene Ergänzungsvorschläge auf Basis ruhiger Bausteine
- einfache Kombinations- und Rezeptideen aus zuhause vorhandenen Lebensmitteln
- Optionen für Erinnerungen, Hinweise und kleine Routinen
- PWA-Vorbereitung mit Web App Manifest, Service Worker und optionalem Web Push
- PWA-Grundlage mit Web App Manifest, Service Worker und optionalem Web Push
## Lokal starten
@@ -47,28 +43,104 @@ Die App legt Daten standardmäßig unter `./data` ab.
Wichtige Umgebungsvariablen:
- `NOURI_SECRET_KEY`: Session-Secret für Produktion
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron
- `NOURI_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_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_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push
- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io`
## Migration von 0.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
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:
Wichtig für die Trennung zwischen lokal und Produktion:
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
- auf Cloudron nutzt Nouri `/app/data`
- `data/` ist in `.gitignore` und `.dockerignore` ausgeschlossen und wird weder eingecheckt noch ins Image kopiert
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten
- `data/` ist nicht für Git oder das Paket gedacht
- 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
+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
import json
import os
import secrets
from datetime import date, timedelta
@@ -16,10 +17,14 @@ from .constants import (
BUILDER_OPTIONS,
DAYPARTS,
DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS,
ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS,
NOTIFICATION_CHANNEL_OPTIONS,
ROLE_LABELS,
SUGGESTION_STYLE_LABELS,
SUGGESTION_STYLE_OPTIONS,
VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS,
WEEKDAY_OPTIONS,
@@ -30,6 +35,7 @@ from .main import main_bp
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
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:
@@ -50,11 +56,38 @@ def load_secret_key(data_dir: Path) -> str:
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:
root_dir = Path(__file__).resolve().parent.parent
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
upload_dir = data_dir / "uploads"
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)
ensure_upload_structure(upload_dir)
@@ -69,7 +102,10 @@ def create_app() -> Flask:
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
SESSION_COOKIE_HTTPONLY=True,
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_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
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_options": BUILDER_OPTIONS,
"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_descriptions": VISIBILITY_DESCRIPTIONS,
"role_labels": ROLE_LABELS,
@@ -104,6 +144,7 @@ def create_app() -> Flask:
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
"today": date.today(),
"app_version": app.config["APP_VERSION"],
"app_release_url": app.config["RELEASE_URL"],
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
@@ -159,6 +200,9 @@ def create_app() -> Flask:
content_type = response.headers.get("Content-Type", "")
if content_type.startswith("text/html"):
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
@app.errorhandler(413)
+28
View File
@@ -289,3 +289,31 @@ def category_update(category_id: int):
get_db().commit()
flash("Die Zuordnung wurde aktualisiert.", "success")
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()
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:
if not username:
return "Bitte einen Benutzernamen eintragen."
@@ -140,6 +146,8 @@ def setup():
error = "Bitte ein Passwort vergeben."
elif error is None and password != password_repeat:
error = "Die Passwörter stimmen nicht überein."
elif error is None:
error = validate_password_strength(password)
if error is None:
database.execute(
@@ -244,6 +252,8 @@ def change_password():
error = "Bitte ein neues Passwort eintragen."
elif new_password != new_password_repeat:
error = "Die neuen Passwörter stimmen nicht überein."
else:
error = validate_password_strength(new_password)
if error is None:
get_db().execute(
@@ -289,6 +299,10 @@ def validate_admin_user_form(
return "Bitte ein Passwort vergeben."
if password and password != password_repeat:
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:
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
return None
+28 -2
View File
@@ -38,7 +38,8 @@ BUILDER_LABELS = {
"protein": "Proteinquelle",
"carb": "Kohlenhydratquelle",
"veg": "Gemüse / Ballaststoffquelle",
"nuts": "Nüsse / Samen",
"nuts": "Nüsse",
"seeds": "Saaten",
"fruit": "Obst",
"dairy": "Milchprodukt",
"neutral": "Neutral / sonstiges",
@@ -48,7 +49,8 @@ BUILDER_DESCRIPTIONS = {
"protein": "Passt eher zu sättigenden Eiweißquellen.",
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
"veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.",
"nuts": "Passt eher zu Nüssen oder Samen.",
"nuts": "Passt eher zu Nüssen.",
"seeds": "Passt eher zu Saaten.",
"fruit": "Passt eher zu Obst.",
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
"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()]
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 = [
(0, "Montag"),
(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
CURRENT_SCHEMA_VERSION = "0.6.0"
CURRENT_SCHEMA_VERSION = "1.1.1"
def get_db() -> sqlite3.Connection:
@@ -18,9 +18,11 @@ def get_db() -> sqlite3.Connection:
g.db = sqlite3.connect(
current_app.config["DATABASE_PATH"],
detect_types=sqlite3.PARSE_DECLTYPES,
timeout=30,
)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA foreign_keys = ON")
g.db.execute("PRAGMA busy_timeout = 30000")
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", "target_user_id INTEGER")
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"):
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,
push_enabled INTEGER NOT NULL DEFAULT 0,
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_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
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,
remind_small_snack 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(
"""
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", "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:
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, "visibility TEXT NOT NULL DEFAULT 'shared'")
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_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:
database.execute(
@@ -378,6 +425,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
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(
"""
@@ -416,6 +469,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
ON shopping_needs (household_id, activation_date, is_activated)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_hidden_generated_suggestions_user
ON hidden_generated_suggestions (user_id)
"""
)
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
+426 -46
View File
@@ -4,6 +4,7 @@ from collections import defaultdict
from datetime import date, datetime, timedelta
from itertools import product
from pathlib import Path
import sqlite3
from flask import (
Blueprint,
@@ -27,10 +28,14 @@ from .constants import (
DEFAULT_CATEGORY_BUILDERS,
DAY_TEMPLATE_NAME_SUGGESTIONS,
DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS,
ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS,
ITEM_SET_NAME_SUGGESTIONS,
NOTIFICATION_CHANNEL_OPTIONS,
SUGGESTION_STYLE_LABELS,
SUGGESTION_STYLE_OPTIONS,
VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS,
WEEKDAY_OPTIONS,
@@ -75,8 +80,10 @@ def refresh_due_context():
if getattr(g, "user", None) is None:
return None
if request.method == "GET" and endpoint.startswith("main."):
ensure_user_settings_row()
try:
activate_due_shopping_needs()
except sqlite3.OperationalError:
current_app.logger.warning("Due shopping needs could not be activated during this request.")
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(
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
"INSERT INTO user_settings (user_id) VALUES (?)",
(g.user["id"],),
)
if commit:
get_db().commit()
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()
if row is None:
return {}
settings = dict(row)
return settings
settings.update(dict(row))
boolean_fields = {
"reminders_enabled",
"push_enabled",
@@ -190,6 +232,9 @@ def get_user_settings() -> dict:
"show_planned_not_shopped",
"remind_tomorrow_if_sparse",
"remind_week_if_sparse",
"push_missing_breakfast",
"push_missing_lunch",
"push_missing_dinner",
"suggest_home_for_today",
"remind_small_snack",
"remind_nuts",
@@ -200,6 +245,8 @@ def get_user_settings() -> dict:
for field in boolean_fields:
settings[field] = bool(settings.get(field))
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
@@ -220,6 +267,24 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") ->
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:
return (
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
entry["owner_name"] = owner_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_shared"] = entry.get("visibility") == "shared"
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam")
entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "")
entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}"
entry["for_label"] = f"Für {target_name}" if target_name else "Für alle"
if target_name:
entry["for_label"] = f"Für {target_name}"
elif entry["is_personal"]:
entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}"
else:
entry["for_label"] = "Für alle"
entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
return entry
@@ -501,6 +573,44 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
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(
*,
kind: str | None = None,
@@ -589,6 +699,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
{
"name": request.form.get("name", "").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(),
"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")),
@@ -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()],
"quick_food_name": request.form.get("quick_food_name", "").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(),
}
)
@@ -608,9 +720,9 @@ def create_quick_food_from_form(form_data: dict) -> int:
cursor = get_db().execute(
"""
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(),
@@ -619,6 +731,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
form_data["visibility"],
form_data["quick_food_name"],
form_data["quick_food_category"],
form_data["quick_food_energy_density"],
form_data["quick_food_note"],
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}))
def generated_suggestion_key(component_ids: list[int]) -> str:
signature = normalized_component_signature(component_ids)
return "generated:" + "-".join(str(component_id) for component_id in signature)
def fetch_hidden_generated_suggestion_keys() -> set[str]:
rows = get_db().execute(
"""
SELECT suggestion_key
FROM hidden_generated_suggestions
WHERE user_id = ?
""",
(g.user["id"],),
).fetchall()
return {row["suggestion_key"] for row in rows}
def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
names = [item["name"] for item in combo]
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2:
@@ -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]:
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"}:
target_patterns = [
("carb", "dairy", "fruit"),
("carb", "dairy", "nuts"),
("carb", "fruit", "dairy"),
{
"slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
"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:
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] = []
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:
groups = [builder_groups.get(builder_key, []) for builder_key in pattern]
if any(not group for group in groups):
slot_candidates = []
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
for combo in product(*groups):
for combo in product(*slot_candidates):
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
seen_signatures.add(signature)
combo_items = list(combo)
suggestions.append(
{
"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],
"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:
return suggestions
if len(suggestions) >= limit * 3:
break
if len(suggestions) >= limit * 3:
break
return suggestions
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
settings = get_user_settings()
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
hidden_keys = fetch_hidden_generated_suggestion_keys()
home_foods = [
item
for item in fetch_items(kind="food", availability="home")
if item_matches_daypart(item, daypart_id)
]
home_food_ids = {item["id"] for item in home_foods}
home_food_map = {int(item["id"]): item for item in home_foods}
visible_foods = [
item
for item in fetch_items(kind="food", include_archived=False)
if item_matches_daypart(item, daypart_id)
]
visible_food_map = {int(item["id"]): item for item in visible_foods}
suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set()
meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)]
for meal in meals:
if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]):
signature = normalized_component_signature(meal["component_ids"])
if not meal["component_ids"]:
continue
component_ids = [int(component_id) for component_id in meal["component_ids"]]
if not all(component_id in visible_food_map for component_id in component_ids):
continue
signature = normalized_component_signature(component_ids)
if signature in seen_signatures:
continue
component_items = [visible_food_map[component_id] for component_id in component_ids]
available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map]
missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids]
if not available_items:
continue
if missing_items and len(missing_items) > 2:
continue
seen_signatures.add(signature)
if missing_items:
missing_names = [item["name"] for item in missing_items]
suggestions.append(
{
"title": meal["name"],
"reason": f"Es fehlt noch: {', '.join(missing_names)}",
"component_ids": component_ids,
"existing_item_id": meal["id"],
"visibility": meal["visibility"],
"daypart_id": daypart_id or meal.get("primary_daypart_id"),
"missing_component_ids": [item["id"] for item in missing_items],
"missing_components": missing_names,
"needs_shopping": True,
"is_generated": False,
"suggestion_key": None,
"score": score_suggestion_components(available_items, daypart_slug=daypart_slug, settings=settings) + 18 - (len(missing_items) * 4),
}
)
else:
suggestions.append(
{
"title": meal["name"],
"reason": "Zuhause vorhanden",
"component_ids": meal["component_ids"],
"component_ids": component_ids,
"existing_item_id": meal["id"],
"visibility": meal["visibility"],
"daypart_id": daypart_id or meal.get("primary_daypart_id"),
"missing_component_ids": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": False,
"suggestion_key": None,
"score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40,
}
)
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):
signature = normalized_component_signature(suggestion["component_ids"])
if signature in seen_signatures:
if signature in seen_signatures or suggestion["suggestion_key"] in hidden_keys:
continue
seen_signatures.add(signature)
component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map]
suggestion["score"] = score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings)
suggestions.append(suggestion)
deduped: list[dict] = []
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:
continue
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)
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 = {
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
"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",
"component_ids": [],
"existing_item_id": item["id"] if item["kind"] == "meal" else None,
"visibility": item["visibility"],
"daypart_id": daypart_id,
"missing_component_ids": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": False,
"suggestion_key": None,
}
for item in archived_items[:2]
]
@@ -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.")
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:
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
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 = []
day_entries = fetch_day_plan_entries(selected_date)
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"],
limit=20,
)
search_candidates = dedupe_items(meal_candidates + food_candidates, limit=24)
entry_item_ids = [int(entry["item_id"]) for entry in entries]
sections.append(
{
@@ -1592,10 +1868,18 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
"candidates": candidates,
"meal_candidates": meal_candidates,
"food_candidates": food_candidates,
"search_candidates": search_candidates,
"recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3),
"suggestions": build_daypart_suggestions(daypart["id"]),
"balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids),
"selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None,
"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"],
"summary_items": [entry["item_name"] for entry in entries][:2],
"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=form_data,
energy_density_options=ENERGY_DENSITY_OPTIONS,
visibility_options=VISIBILITY_FORM_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():
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"))
@login_required
def day_template_create():
@@ -2588,18 +2909,24 @@ def settings_view():
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
elif form_name == "reminders":
ensure_user_settings_row()
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
get_db().execute(
"""
UPDATE user_settings
SET reminders_enabled = ?,
push_enabled = ?,
notification_channel = ?,
suggestion_style = ?,
energy_preference = ?,
remind_before_shopping = ?,
remind_on_shopping_day = ?,
show_missing_for_upcoming_week = ?,
show_planned_not_shopped = ?,
remind_tomorrow_if_sparse = ?,
remind_week_if_sparse = ?,
push_missing_breakfast = ?,
push_missing_lunch = ?,
push_missing_dinner = ?,
suggest_home_for_today = ?,
remind_small_snack = ?,
remind_nuts = ?,
@@ -2613,12 +2940,17 @@ def settings_view():
parse_checkbox("reminders_enabled", True),
parse_checkbox("push_enabled", False),
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_on_shopping_day", True),
parse_checkbox("show_missing_for_upcoming_week", True),
parse_checkbox("show_planned_not_shopped", True),
parse_checkbox("remind_tomorrow_if_sparse", True),
parse_checkbox("remind_week_if_sparse", True),
parse_checkbox("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("remind_small_snack", False),
parse_checkbox("remind_nuts", False),
@@ -2831,6 +3163,7 @@ def item_create(kind: str):
form_data = {
"name": request.args.get("name", "").strip(),
"category": "",
"energy_density": "neutral",
"note": "",
"visibility": "shared",
"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()],
"quick_food_name": "",
"quick_food_category": "",
"quick_food_energy_density": "neutral",
"quick_food_note": "",
}
@@ -2878,9 +3212,9 @@ def item_create(kind: str):
cursor = get_db().execute(
"""
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(),
@@ -2890,6 +3224,7 @@ def item_create(kind: str):
kind,
form_data["name"],
form_data["category"],
form_data["energy_density"],
form_data["note"],
photo_filename,
g.user["id"],
@@ -2921,6 +3256,7 @@ def item_edit(item_id: int):
form_data = {
"name": item["name"],
"category": item["category"] or "",
"energy_density": item.get("energy_density") or "neutral",
"note": item["note"] or "",
"visibility": item["visibility"],
"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 [],
"quick_food_name": "",
"quick_food_category": "",
"quick_food_energy_density": "neutral",
"quick_food_note": "",
}
@@ -2970,6 +3307,7 @@ def item_edit(item_id: int):
UPDATE items
SET name = ?,
category = ?,
energy_density = ?,
note = ?,
visibility = ?,
target_user_id = ?,
@@ -2981,6 +3319,7 @@ def item_edit(item_id: int):
(
form_data["name"],
form_data["category"],
form_data["energy_density"],
form_data["note"],
form_data["visibility"],
form_data["target_user_id"],
@@ -3303,14 +3642,23 @@ def planner_day():
selected_item_raw = request.args.get("item_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_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(
"planner/day.html",
selected_date=selected_date,
previous_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(),
visibility_options=VISIBILITY_FORM_OPTIONS,
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}")
@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")
@login_required
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,
push_enabled INTEGER NOT NULL DEFAULT 0,
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_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
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,
remind_small_snack 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
);
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
@@ -96,6 +119,7 @@ CREATE TABLE IF NOT EXISTS items (
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
name TEXT NOT NULL,
category TEXT,
energy_density TEXT NOT NULL DEFAULT 'neutral',
note TEXT,
photo_filename TEXT,
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,
.ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -260,32 +261,54 @@ h3,
@media (min-width: 1081px) {
.site-header {
grid-template-columns: 1fr;
grid-template-columns: auto minmax(0, 1fr);
column-gap: 1.5rem;
row-gap: 0.9rem;
align-items: center;
}
.desktop-header-main {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1.4rem;
display: contents;
}
.desktop-header-sub {
display: flex;
justify-content: flex-start;
display: contents;
}
.desktop-nav {
width: 100%;
grid-column: 2;
grid-row: 1;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: center;
overflow-x: auto;
scrollbar-width: none;
}
.desktop-nav::-webkit-scrollbar {
display: none;
}
.desktop-actions {
width: 100%;
grid-column: 2;
grid-row: 2;
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
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,
@@ -915,6 +938,31 @@ legend {
background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
}
.planner-entry-edit {
margin-top: 0.85rem;
}
.planner-entry-edit > summary {
width: fit-content;
cursor: pointer;
list-style: none;
}
.planner-entry-edit > summary::-webkit-details-marker {
display: none;
}
.planner-entry-inline-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
margin-top: 0.8rem;
}
.planner-entry-inline-form .wide {
grid-column: 1 / -1;
}
.template-card,
.template-list-card,
.suggestion-card {
@@ -930,6 +978,23 @@ legend {
gap: 0.9rem;
}
.template-list-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
}
.template-list-card-actions form {
margin: 0;
}
.template-list-card .ghost-button,
.template-list-card .button {
width: auto;
align-self: flex-start;
}
.week-template-row {
padding: 1rem;
border-radius: 18px;
@@ -1483,6 +1548,10 @@ legend {
min-width: 100%;
}
.planner-entry-inline-form {
grid-template-columns: 1fr;
}
.mobile-nav-stack {
position: fixed;
left: 0.75rem;
+47 -2
View File
@@ -78,8 +78,8 @@
const applyFilter = () => {
const term = input.value.trim().toLowerCase();
if (!term) {
items.forEach((item) => {
item.hidden = false;
items.forEach((item, index) => {
item.hidden = hasLimit ? index >= resultLimit : false;
});
syncGroups();
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", () => {
initMobileSheet();
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 STATIC_ASSETS = [
"/static/css/styles.css",
+6
View File
@@ -66,6 +66,12 @@
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
</button>
</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>
</article>
{% endfor %}
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="de" data-theme="auto">
<head>
<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>
<meta name="theme-color" content="#de9862">
<meta name="mobile-web-app-capable" content="yes">
@@ -91,7 +91,7 @@
<footer class="site-footer">
<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>
</div>
<div class="footer-copy">
+18
View File
@@ -134,8 +134,26 @@
<div>
<strong>{{ suggestion.title }}</strong>
<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>
</article>
{% endfor %}
</div>
+18
View File
@@ -51,8 +51,26 @@
<div>
<strong>{{ suggestion.title }}</strong>
<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>
</article>
{% endfor %}
</div>
+18
View File
@@ -55,6 +55,16 @@
</select>
</label>
<label>
Energiedichte
<select name="energy_density">
{% for value, label in energy_density_options %}
<option value="{{ value }}" {% if form_data.energy_density == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
</label>
<label>
Notiz
<textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea>
@@ -149,6 +159,14 @@
{% endfor %}
</select>
</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">
Notiz
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
+71 -3
View File
@@ -72,6 +72,35 @@
</summary>
<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 %}
<div class="suggestion-card">
<strong>{{ section.balance_suggestion.text }}</strong>
@@ -121,6 +150,19 @@
<h3>Passt gut dazu</h3>
<div class="quick-add-row compact-quick-row">
{% for suggestion in section.recipe_suggestions %}
{% if suggestion.existing_item_id %}
<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="{{ suggestion.existing_item_id }}">
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</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() }}">
@@ -135,6 +177,7 @@
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% endif %}
{% endfor %}
</div>
</div>
@@ -143,10 +186,10 @@
<div class="planner-subsection">
<label class="planner-search">
<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>
<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 }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
@@ -155,7 +198,10 @@
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="picker-row" type="submit">
<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>
</form>
{% endfor %}
@@ -188,6 +234,28 @@
{% if entry.note %}
<p>{{ entry.note }}</p>
{% 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>
{% endfor %}
</div>
+16 -1
View File
@@ -55,7 +55,7 @@
<div class="pwa-card">
<strong>Push-Mitteilungen</strong>
{% 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">
<button class="secondary" type="button" data-push-enable>Push erlauben</button>
<button class="ghost-button" type="button" data-push-disable>Push beenden</button>
@@ -98,6 +98,17 @@
{% endfor %}
</select>
</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>
@@ -112,8 +123,12 @@
<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_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="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>
+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/
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