Compare commits
8 Commits
96ab52e1ba
..
V1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 325101da99 | |||
| b0d1cee5f5 | |||
| a26d519cf2 | |||
| 555fddab80 | |||
| 9ff7a6d57c | |||
| cf5157c496 | |||
| dffbe26423 | |||
| 732e7918af |
@@ -9,3 +9,6 @@ __pycache__/
|
||||
|
||||
data/
|
||||
instance/
|
||||
.cloudron-push.env
|
||||
.env.local
|
||||
.env.push.local
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"author": "Florian Heinz",
|
||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||
"tagline": "einfach essen planen",
|
||||
"version": "0.5.0",
|
||||
"upstreamVersion": "0.5.0",
|
||||
"version": "1.0.0",
|
||||
"upstreamVersion": "1.0.0",
|
||||
"healthCheckPath": "/",
|
||||
"httpPort": 8000,
|
||||
"manifestVersion": 2,
|
||||
|
||||
@@ -11,7 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -r -m -d /home/cloudron cloudron
|
||||
RUN groupadd --gid 1000 cloudron \
|
||||
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/cloudron cloudron
|
||||
|
||||
COPY requirements.txt /app/code/
|
||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Push-Setup für Nouri
|
||||
|
||||
## 1. VAPID-Schlüssel erzeugen
|
||||
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
python scripts/generate_vapid_keys.py
|
||||
```
|
||||
|
||||
Das Script gibt drei Zeilen aus:
|
||||
|
||||
- `NOURI_VAPID_PUBLIC_KEY`
|
||||
- `NOURI_VAPID_PRIVATE_KEY`
|
||||
- `NOURI_VAPID_SUBJECT`
|
||||
|
||||
## 2. In Cloudron eintragen
|
||||
|
||||
In der bestehenden Nouri-App unter `Settings` → `Environment Variables` diese drei Werte anlegen:
|
||||
|
||||
```text
|
||||
NOURI_VAPID_PUBLIC_KEY=...
|
||||
NOURI_VAPID_PRIVATE_KEY=...
|
||||
NOURI_VAPID_SUBJECT=mailto:mail@hnz.io
|
||||
```
|
||||
|
||||
Danach die App neu starten.
|
||||
|
||||
## 3. Auf dem iPhone aktivieren
|
||||
|
||||
1. `nouri.heinz.media` in Safari öffnen
|
||||
2. `Teilen` → `Zum Home-Bildschirm`
|
||||
3. die installierte Web-App öffnen
|
||||
4. in Nouri `Optionen` öffnen
|
||||
5. `Push erlauben` tippen
|
||||
6. danach optional `Test-Mitteilung senden`
|
||||
|
||||
## 4. Bereits vorbereitete lokale Datei
|
||||
|
||||
Wenn lokal bereits eine Datei `.cloudron-push.env` liegt, kannst du deren Werte direkt nach Cloudron übernehmen.
|
||||
|
||||
Die Datei ist absichtlich in `.gitignore`, damit keine geheimen Schlüssel committed werden.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Nouri 0.5.1
|
||||
|
||||
## Highlights
|
||||
|
||||
- Smartphone-Navigation unten neu als echte Erweiterung umgesetzt
|
||||
- obere Nouri-Leiste auf kleinen Geräten nicht mehr sticky, sondern sauber fest positioniert
|
||||
- PWA-Cache für frische Layout- und Einstellungsänderungen bereinigt
|
||||
- Cloudron-Version auf `0.5.1` angehoben
|
||||
|
||||
## Neu in 0.5.1
|
||||
|
||||
### Mobile Navigation
|
||||
|
||||
- `Mehr` ist auf Smartphones kein schwebendes Overlay mehr.
|
||||
- Die zusätzlichen Punkte klappen jetzt direkt aus der unteren Navigation heraus auf.
|
||||
- Die Zusatzpunkte nutzen dieselbe kompakte Größe wie die unteren Menüpunkte.
|
||||
- Der untere Navigationsbereich wird dabei nicht weichgezeichnet.
|
||||
|
||||
### Mobile Header
|
||||
|
||||
- Die obere Nouri-Leiste scrollt auf kleinen Geräten nicht mehr mit dem Inhalt.
|
||||
- Die bisherige `sticky`-Logik für den Header wurde entfernt, damit es keine widersprüchlichen Zustände mehr gibt.
|
||||
|
||||
### PWA
|
||||
|
||||
- Der Service Worker verwendet einen aktualisierten Cache-Namen.
|
||||
- Navigationsseiten werden frischer geladen, damit Änderungen an Einstellungen und Layout nicht an altem Cache hängen bleiben.
|
||||
|
||||
## Cloudron
|
||||
|
||||
- `CloudronManifest.json` wurde auf `0.5.1` angehoben.
|
||||
- Damit lässt sich das Update sauber als neue App-Version ausrollen.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Nouri 0.6.0
|
||||
|
||||
Nouri 0.6.0 bringt die App näher an einen stabilen 1.0-Stand. Der Schwerpunkt liegt auf Reife, Alltagstauglichkeit und einem ruhigeren, verlässlicheren Gesamteindruck statt auf großen neuen Kernfunktionen.
|
||||
|
||||
## Highlights
|
||||
|
||||
### Design und Brand
|
||||
|
||||
- Neues Nouri-App-Icon für Header, Favicon und PWA
|
||||
- Überarbeitete PWA-Icons inklusive zusätzlicher Größen und maskierbarer Variante
|
||||
- Ruhigeres visuelles Finish bei Karten, Fokuszuständen, Setup und leeren Bereichen
|
||||
- Mobile Header-Logik bereinigt, damit der obere Bereich auf Smartphones nicht fest mitscrollt
|
||||
- Desktop-Navigation wieder klarer als durchlaufende Leiste direkt neben dem Logo
|
||||
|
||||
### Bilder und Performance
|
||||
|
||||
- Bild-Uploads werden jetzt in mehrere sinnvolle Größen abgeleitet
|
||||
- Listen und Formulare nutzen responsive Bildauslieferung statt immer die volle Originalgröße
|
||||
- Statische Assets bekommen sauberes Cache-Busting über Versions-URLs
|
||||
- Uploads und statische Marken-Assets werden vorsichtig browserfreundlich gecacht
|
||||
|
||||
### PWA und iPhone-Nutzung
|
||||
|
||||
- Überarbeitetes Web App Manifest
|
||||
- Verbesserter Service Worker für eine stabilere App-Shell
|
||||
- Kleine Offline-Seite für kurze Verbindungsabbrüche
|
||||
- Bessere Einbindung für Homescreen-Nutzung auf iPhones
|
||||
|
||||
### Backup und Stabilität
|
||||
|
||||
- Admin-Bereich in den Optionen für Backup-Export und Restore
|
||||
- Backup als ZIP mit App-Daten und Uploads
|
||||
- Restore mit klarer Bestätigung statt versehentlicher Überschreibung
|
||||
- Freundlichere Behandlung von zu großen Bild-Uploads
|
||||
- Robusterer Umgang mit vorhandenen älteren Datenstrukturen
|
||||
- Startet lokal jetzt auch ohne Pillow-Build auf Python 3.14, statt schon beim Import zu scheitern
|
||||
|
||||
### Planung und Vorschläge
|
||||
|
||||
- Automatische Mahlzeitenvorschläge robuster aufgebaut
|
||||
- Bausteinlogik für Protein, Kohlenhydrate und Gemüse verbessert
|
||||
- Bestehende Kombinationen werden beim Übernehmen besser wiederverwendet
|
||||
- Suche beim Zusammenstellen von Mahlzeiten zeigt nur die drei passendsten Treffer
|
||||
- Kategorie-Richtung von „Brot & Getreide“ in Richtung „Kohlenhydrate“ weitergeführt
|
||||
|
||||
## Technische Änderungen
|
||||
|
||||
- Neue Hilfsmodule für Bildverarbeitung und Backup/Restore
|
||||
- App-Metadaten-Tabelle für robustere Schema-Verwaltung vorbereitet
|
||||
- Cloudron-Version auf 0.6.0 angehoben
|
||||
- Pillow für Bildverarbeitung ergänzt und auf Python 3.14 lokal als optionale Abhängigkeit abgefedert
|
||||
|
||||
## Hinweise zum Update
|
||||
|
||||
- Nach dem Update kann ein harter Reload im Browser sinnvoll sein, damit neue CSS-, JS- und PWA-Dateien sicher geladen werden.
|
||||
- Vor einem Restore empfiehlt sich immer zuerst ein frischer Backup-Export aus der laufenden App.
|
||||
- Vorhandene Kategorien und ältere Daten werden beim Start weiter normalisiert, statt hart ersetzt zu werden.
|
||||
@@ -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.
|
||||
@@ -5,7 +5,7 @@ import secrets
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, g, send_from_directory
|
||||
from flask import Flask, flash, g, redirect, request, send_from_directory, url_for
|
||||
|
||||
from . import db
|
||||
from .admin import admin_bp
|
||||
@@ -16,14 +16,19 @@ 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,
|
||||
)
|
||||
from .images import ensure_upload_structure, image_sizes, image_srcset, image_url
|
||||
from .main import main_bp
|
||||
|
||||
|
||||
@@ -56,7 +61,7 @@ def create_app() -> Flask:
|
||||
db_path = data_dir / "nouri.sqlite3"
|
||||
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
ensure_upload_structure(upload_dir)
|
||||
|
||||
app = Flask(__name__, instance_relative_config=False)
|
||||
app.config.update(
|
||||
@@ -68,7 +73,9 @@ def create_app() -> Flask:
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
APP_VERSION="0.5.0",
|
||||
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
||||
APP_VERSION="1.0.0",
|
||||
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"),
|
||||
@@ -83,6 +90,11 @@ def create_app() -> Flask:
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals() -> dict[str, object]:
|
||||
def asset_url(filename: str) -> str:
|
||||
file_path = root_dir / "nouri" / "static" / filename
|
||||
version = int(file_path.stat().st_mtime) if file_path.exists() else app.config["APP_VERSION"]
|
||||
return url_for("static", filename=filename, v=version)
|
||||
|
||||
return {
|
||||
"item_kind_labels": ITEM_KIND_LABELS,
|
||||
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
|
||||
@@ -91,6 +103,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,
|
||||
@@ -103,18 +119,64 @@ def create_app() -> Flask:
|
||||
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
||||
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
|
||||
"asset_url": asset_url,
|
||||
"image_url": lambda filename, variant="md": image_url(
|
||||
filename,
|
||||
url_for,
|
||||
variant,
|
||||
upload_folder=app.config["UPLOAD_FOLDER"],
|
||||
),
|
||||
"image_srcset": lambda filename: image_srcset(
|
||||
filename,
|
||||
url_for,
|
||||
upload_folder=app.config["UPLOAD_FOLDER"],
|
||||
),
|
||||
"image_sizes": image_sizes,
|
||||
}
|
||||
|
||||
@app.get("/uploads/<path:filename>")
|
||||
def uploaded_file(filename: str):
|
||||
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename, max_age=60 * 60 * 24 * 30)
|
||||
response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
|
||||
return response
|
||||
|
||||
@app.get("/app.webmanifest")
|
||||
def webmanifest():
|
||||
return send_from_directory(root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json")
|
||||
response = send_from_directory(
|
||||
root_dir / "nouri" / "static" / "pwa",
|
||||
"app.webmanifest",
|
||||
mimetype="application/manifest+json",
|
||||
max_age=60 * 30,
|
||||
)
|
||||
response.headers["Cache-Control"] = "public, max-age=1800"
|
||||
return response
|
||||
|
||||
@app.get("/service-worker.js")
|
||||
def service_worker():
|
||||
return send_from_directory(root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript")
|
||||
response = send_from_directory(
|
||||
root_dir / "nouri" / "static" / "pwa",
|
||||
"service-worker.js",
|
||||
mimetype="application/javascript",
|
||||
max_age=0,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
@app.after_request
|
||||
def apply_cache_policy(response):
|
||||
if response.direct_passthrough:
|
||||
return response
|
||||
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)
|
||||
def upload_too_large(_error):
|
||||
flash("Das hochgeladene Bild ist etwas zu groß. Eine kleinere Datei passt hier besser.", "error")
|
||||
return redirect(request.referrer or url_for("main.dashboard"))
|
||||
|
||||
return app
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKUP_FILENAME_PREFIX = "nouri-backup"
|
||||
RESTORE_CONFIRMATION_TEXT = "WIEDERHERSTELLEN"
|
||||
|
||||
|
||||
def list_backup_tables(database: sqlite3.Connection) -> list[str]:
|
||||
rows = database.execute(
|
||||
"""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
"""
|
||||
).fetchall()
|
||||
return [row["name"] for row in rows]
|
||||
|
||||
|
||||
def export_backup_archive(
|
||||
database: sqlite3.Connection,
|
||||
upload_folder: str | Path,
|
||||
app_version: str,
|
||||
) -> tuple[str, str]:
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
backup_name = f"{BACKUP_FILENAME_PREFIX}-{timestamp}.zip"
|
||||
temp_handle = tempfile.NamedTemporaryFile(prefix="nouri-backup-", suffix=".zip", delete=False)
|
||||
temp_handle.close()
|
||||
archive_path = temp_handle.name
|
||||
|
||||
tables = list_backup_tables(database)
|
||||
payload = {
|
||||
"meta": {
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"app_version": app_version,
|
||||
"format_version": 1,
|
||||
},
|
||||
"tables": {},
|
||||
}
|
||||
|
||||
for table_name in tables:
|
||||
rows = database.execute(f"SELECT * FROM {table_name}").fetchall()
|
||||
payload["tables"][table_name] = [dict(row) for row in rows]
|
||||
|
||||
uploads_root = Path(upload_folder)
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
if uploads_root.exists():
|
||||
for file_path in uploads_root.rglob("*"):
|
||||
if file_path.is_file():
|
||||
relative_path = file_path.relative_to(uploads_root)
|
||||
archive.write(file_path, f"uploads/{relative_path.as_posix()}")
|
||||
|
||||
return archive_path, backup_name
|
||||
|
||||
|
||||
def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-"))
|
||||
for member in archive.infolist():
|
||||
if not member.filename.startswith("uploads/") or member.is_dir():
|
||||
continue
|
||||
relative_target = member.filename.removeprefix("uploads/").lstrip("/")
|
||||
if not relative_target:
|
||||
continue
|
||||
target_path = temp_dir / relative_target
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(member, "r") as source, target_path.open("wb") as destination:
|
||||
shutil.copyfileobj(source, destination)
|
||||
return temp_dir
|
||||
|
||||
|
||||
def _replace_uploads(temp_dir: Path, upload_folder: str | Path) -> None:
|
||||
upload_root = Path(upload_folder)
|
||||
previous_root = upload_root.with_name(f"{upload_root.name}-previous")
|
||||
if previous_root.exists():
|
||||
shutil.rmtree(previous_root)
|
||||
if upload_root.exists():
|
||||
upload_root.rename(previous_root)
|
||||
upload_root.mkdir(parents=True, exist_ok=True)
|
||||
for file_path in temp_dir.rglob("*"):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
relative_path = file_path.relative_to(temp_dir)
|
||||
target_path = upload_root / relative_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(file_path, target_path)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
shutil.rmtree(previous_root, ignore_errors=True)
|
||||
|
||||
|
||||
def restore_backup_archive(
|
||||
database: sqlite3.Connection,
|
||||
upload_folder: str | Path,
|
||||
backup_file,
|
||||
) -> dict:
|
||||
backup_bytes = backup_file.read()
|
||||
if not backup_bytes:
|
||||
raise ValueError("Bitte ein gültiges Backup auswählen.")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(backup_bytes)) as archive:
|
||||
try:
|
||||
backup_payload = json.loads(archive.read("backup.json").decode("utf-8"))
|
||||
except KeyError as exc:
|
||||
raise ValueError("Im Backup fehlt die Datei backup.json.") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Das Backup konnte nicht gelesen werden.") from exc
|
||||
|
||||
tables = backup_payload.get("tables")
|
||||
if not isinstance(tables, dict):
|
||||
raise ValueError("Das Backup enthält keine gültigen Tabellen-Daten.")
|
||||
|
||||
current_tables = list_backup_tables(database)
|
||||
restore_tables = [table for table in current_tables if table in tables]
|
||||
|
||||
upload_temp_dir = _extract_uploads_to_temp(archive)
|
||||
|
||||
try:
|
||||
database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table_name in reversed(restore_tables):
|
||||
database.execute(f"DELETE FROM {table_name}")
|
||||
database.execute("DELETE FROM sqlite_sequence")
|
||||
|
||||
for table_name in restore_tables:
|
||||
rows = tables.get(table_name, [])
|
||||
if not rows:
|
||||
continue
|
||||
columns = list(rows[0].keys())
|
||||
placeholders = ", ".join(["?"] * len(columns))
|
||||
column_list = ", ".join(columns)
|
||||
for row in rows:
|
||||
values = [row.get(column) for column in columns]
|
||||
database.execute(
|
||||
f"INSERT INTO {table_name} ({column_list}) VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
finally:
|
||||
database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
_replace_uploads(upload_temp_dir, upload_folder)
|
||||
except Exception:
|
||||
shutil.rmtree(upload_temp_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return backup_payload.get("meta", {})
|
||||
@@ -8,7 +8,7 @@ DAYPARTS = [
|
||||
]
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
"Brot & Getreide",
|
||||
"Kohlenhydrate",
|
||||
"Milchprodukt",
|
||||
"Obst",
|
||||
"Gemüse",
|
||||
@@ -21,6 +21,7 @@ DEFAULT_CATEGORIES = [
|
||||
]
|
||||
|
||||
DEFAULT_CATEGORY_BUILDERS = {
|
||||
"Kohlenhydrate": "carb",
|
||||
"Brot & Getreide": "carb",
|
||||
"Milchprodukt": "dairy",
|
||||
"Obst": "fruit",
|
||||
@@ -37,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",
|
||||
@@ -47,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.",
|
||||
@@ -55,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"),
|
||||
|
||||
@@ -10,6 +10,8 @@ from werkzeug.security import generate_password_hash
|
||||
|
||||
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
||||
|
||||
CURRENT_SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
if "db" not in g:
|
||||
@@ -47,7 +49,36 @@ def add_column_if_missing(database: sqlite3.Connection, table_name: str, definit
|
||||
database.execute(f"ALTER TABLE {table_name} ADD COLUMN {definition}")
|
||||
|
||||
|
||||
def ensure_meta_table(database: sqlite3.Connection) -> None:
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def get_meta(database: sqlite3.Connection, key: str) -> str | None:
|
||||
row = database.execute("SELECT value FROM app_meta WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def set_meta(database: sqlite3.Connection, key: str, value: str) -> None:
|
||||
database.execute(
|
||||
"""
|
||||
INSERT INTO app_meta (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||
ensure_meta_table(database)
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS households (
|
||||
@@ -96,6 +127,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")
|
||||
@@ -117,12 +149,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,
|
||||
@@ -153,6 +190,19 @@ 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 shopping_needs (
|
||||
@@ -185,6 +235,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(
|
||||
@@ -211,6 +268,41 @@ def first_user_id(database: sqlite3.Connection) -> int | None:
|
||||
|
||||
def sync_default_categories(database: sqlite3.Connection) -> None:
|
||||
for household_id in household_ids(database):
|
||||
legacy = database.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM household_categories
|
||||
WHERE household_id = ? AND name = 'Brot & Getreide'
|
||||
LIMIT 1
|
||||
""",
|
||||
(household_id,),
|
||||
).fetchone()
|
||||
updated = database.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM household_categories
|
||||
WHERE household_id = ? AND name = 'Kohlenhydrate'
|
||||
LIMIT 1
|
||||
""",
|
||||
(household_id,),
|
||||
).fetchone()
|
||||
if legacy and not updated:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE household_categories
|
||||
SET name = 'Kohlenhydrate', builder_key = 'carb'
|
||||
WHERE id = ?
|
||||
""",
|
||||
(legacy["id"],),
|
||||
)
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET category = 'Kohlenhydrate'
|
||||
WHERE household_id = ? AND category = 'Brot & Getreide'
|
||||
""",
|
||||
(household_id,),
|
||||
)
|
||||
for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10):
|
||||
database.execute(
|
||||
"""
|
||||
@@ -230,6 +322,7 @@ def sync_default_categories(database: sqlite3.Connection) -> None:
|
||||
|
||||
|
||||
def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
ensure_meta_table(database)
|
||||
add_column_if_missing(database, "users", "household_id INTEGER")
|
||||
add_column_if_missing(database, "users", "email TEXT")
|
||||
add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'")
|
||||
@@ -237,10 +330,10 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
add_column_if_missing(database, "users", "updated_at TEXT")
|
||||
|
||||
default_household_id = ensure_default_household(database)
|
||||
database.execute("UPDATE households SET shopping_weekday = COALESCE(shopping_weekday, 5)")
|
||||
database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)")
|
||||
database.execute("UPDATE households SET shopping_weekday = 5 WHERE shopping_weekday IS NULL")
|
||||
database.execute("UPDATE households SET shopping_prep_days = 1 WHERE shopping_prep_days IS NULL")
|
||||
database.execute(
|
||||
"UPDATE households SET shopping_reminder_time = COALESCE(NULLIF(shopping_reminder_time, ''), '18:00')"
|
||||
"UPDATE households SET shopping_reminder_time = '18:00' WHERE shopping_reminder_time IS NULL OR shopping_reminder_time = ''"
|
||||
)
|
||||
database.execute(
|
||||
"UPDATE users SET household_id = ? WHERE household_id IS NULL",
|
||||
@@ -265,8 +358,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(
|
||||
@@ -311,6 +410,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(
|
||||
"""
|
||||
@@ -349,6 +454,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
ON shopping_needs (household_id, activation_date, is_activated)
|
||||
"""
|
||||
)
|
||||
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
|
||||
|
||||
|
||||
def apply_schema(database: sqlite3.Connection) -> None:
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
PILLOW_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - local fallback when Pillow is unavailable
|
||||
Image = None
|
||||
ImageOps = None
|
||||
UnidentifiedImageError = OSError
|
||||
PILLOW_AVAILABLE = False
|
||||
|
||||
|
||||
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||
IMAGE_VARIANTS = {
|
||||
"sm": {"width": 320, "quality": 76},
|
||||
"md": {"width": 720, "quality": 82},
|
||||
"lg": {"width": 1280, "quality": 86},
|
||||
}
|
||||
DEFAULT_RENDERED_FORMAT = "webp"
|
||||
ORIGINAL_MAX_WIDTH = 1600
|
||||
|
||||
|
||||
def allowed_image_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def ensure_upload_structure(upload_folder: str | Path) -> None:
|
||||
upload_root = Path(upload_folder)
|
||||
upload_root.mkdir(parents=True, exist_ok=True)
|
||||
(upload_root / "variants").mkdir(parents=True, exist_ok=True)
|
||||
for variant_name in IMAGE_VARIANTS:
|
||||
(upload_root / "variants" / variant_name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def build_variant_filename(filename: str, variant_name: str) -> str:
|
||||
source = Path(filename)
|
||||
return f"{source.stem}__{variant_name}.webp"
|
||||
|
||||
|
||||
def build_variant_relative_path(filename: str, variant_name: str) -> str:
|
||||
return f"variants/{variant_name}/{build_variant_filename(filename, variant_name)}"
|
||||
|
||||
|
||||
def remove_photo_assets(upload_folder: str | Path, filename: str | None) -> None:
|
||||
if not filename:
|
||||
return
|
||||
upload_root = Path(upload_folder)
|
||||
original_path = upload_root / filename
|
||||
if original_path.exists():
|
||||
original_path.unlink()
|
||||
for variant_name in IMAGE_VARIANTS:
|
||||
variant_path = upload_root / build_variant_relative_path(filename, variant_name)
|
||||
if variant_path.exists():
|
||||
variant_path.unlink()
|
||||
|
||||
|
||||
def _open_image(upload: FileStorage) -> Image.Image:
|
||||
if not PILLOW_AVAILABLE or Image is None or ImageOps is None:
|
||||
raise OSError("Pillow ist nicht verfügbar.")
|
||||
upload.stream.seek(0)
|
||||
image = Image.open(upload.stream)
|
||||
image.load()
|
||||
return ImageOps.exif_transpose(image)
|
||||
|
||||
|
||||
def _prepare_image(image: Image.Image) -> Image.Image:
|
||||
if not PILLOW_AVAILABLE:
|
||||
return image
|
||||
if image.mode not in {"RGB", "RGBA"}:
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
return image
|
||||
|
||||
|
||||
def _resize_copy(image: Image.Image, width: int) -> Image.Image:
|
||||
resized = image.copy()
|
||||
resized.thumbnail((width, width * 3), Image.Resampling.LANCZOS)
|
||||
return resized
|
||||
|
||||
|
||||
def _save_image(image: Image.Image, destination: Path, quality: int) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(
|
||||
destination,
|
||||
format=DEFAULT_RENDERED_FORMAT.upper(),
|
||||
quality=quality,
|
||||
method=6,
|
||||
)
|
||||
|
||||
|
||||
def save_photo_with_variants(
|
||||
upload: FileStorage | None,
|
||||
upload_folder: str | Path,
|
||||
current_filename: str | None = None,
|
||||
) -> str | None:
|
||||
if not upload or not upload.filename:
|
||||
return current_filename
|
||||
|
||||
if not allowed_image_file(upload.filename):
|
||||
raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.")
|
||||
|
||||
ensure_upload_structure(upload_folder)
|
||||
|
||||
original_name = secure_filename(upload.filename)
|
||||
extension = original_name.rsplit(".", 1)[1].lower()
|
||||
|
||||
try:
|
||||
image = _prepare_image(_open_image(upload))
|
||||
filename = f"{uuid.uuid4().hex}.webp"
|
||||
original_path = Path(upload_folder) / filename
|
||||
optimized = _resize_copy(image, ORIGINAL_MAX_WIDTH)
|
||||
_save_image(optimized, original_path, quality=88)
|
||||
for variant_name, config in IMAGE_VARIANTS.items():
|
||||
variant_image = _resize_copy(image, int(config["width"]))
|
||||
variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant_name)
|
||||
_save_image(variant_image, variant_path, quality=int(config["quality"]))
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
filename = f"{uuid.uuid4().hex}.{extension}"
|
||||
original_path = Path(upload_folder) / filename
|
||||
upload.stream.seek(0)
|
||||
upload.save(original_path)
|
||||
|
||||
if current_filename and current_filename != filename:
|
||||
remove_photo_assets(upload_folder, current_filename)
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def image_url(filename: str | None, url_builder, variant: str = "md", upload_folder: str | Path | None = None) -> str | None:
|
||||
if not filename:
|
||||
return None
|
||||
if variant in IMAGE_VARIANTS:
|
||||
if upload_folder is not None:
|
||||
variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant)
|
||||
if variant_path.exists():
|
||||
return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant))
|
||||
else:
|
||||
return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant))
|
||||
return url_builder("uploaded_file", filename=filename)
|
||||
|
||||
|
||||
def image_srcset(filename: str | None, url_builder, upload_folder: str | Path | None = None) -> str:
|
||||
if not filename:
|
||||
return ""
|
||||
parts = []
|
||||
for variant_name, config in IMAGE_VARIANTS.items():
|
||||
variant_url = image_url(filename, url_builder, variant_name, upload_folder=upload_folder)
|
||||
if variant_url and (not upload_folder or variant_url != url_builder("uploaded_file", filename=filename)):
|
||||
parts.append(f"{variant_url} {config['width']}w")
|
||||
original_width = max(config["width"] for config in IMAGE_VARIANTS.values()) + 320
|
||||
parts.append(f"{url_builder('uploaded_file', filename=filename)} {original_width}w")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def image_sizes(card: str = "grid") -> str:
|
||||
if card == "detail":
|
||||
return "(max-width: 720px) 100vw, 720px"
|
||||
return "(max-width: 720px) 42vw, (max-width: 1080px) 28vw, 180px"
|
||||
|
||||
|
||||
def upload_file_size_ok(upload: FileStorage | None, max_bytes: int) -> bool:
|
||||
if not upload or not upload.filename:
|
||||
return True
|
||||
stream = upload.stream
|
||||
current_position = stream.tell()
|
||||
stream.seek(0, os.SEEK_END)
|
||||
size = stream.tell()
|
||||
stream.seek(current_position)
|
||||
return size <= max_bytes
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
after_this_request,
|
||||
current_app,
|
||||
flash,
|
||||
g,
|
||||
@@ -14,33 +15,41 @@ from flask import (
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from .auth import login_required
|
||||
from .auth import admin_required, login_required
|
||||
from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_backup_archive
|
||||
from .constants import (
|
||||
AVAILABILITY_LABELS,
|
||||
BUILDER_LABELS,
|
||||
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,
|
||||
WEEK_TEMPLATE_NAME_SUGGESTIONS,
|
||||
)
|
||||
from .db import get_db
|
||||
from .images import (
|
||||
allowed_image_file,
|
||||
save_photo_with_variants,
|
||||
upload_file_size_ok,
|
||||
)
|
||||
from .push import push_is_configured, push_public_key, send_push_message
|
||||
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||
ACTIVE_STATE_OPTIONS = [
|
||||
("", "Alle aktiven"),
|
||||
("home", "Zuhause"),
|
||||
@@ -185,6 +194,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",
|
||||
@@ -195,6 +207,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
|
||||
|
||||
|
||||
@@ -215,6 +229,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 = ? "
|
||||
@@ -260,29 +292,14 @@ def normalize_target_user_id(raw: str | None) -> int | None:
|
||||
return target_id if target_id in allowed else None
|
||||
|
||||
|
||||
def allowed_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def save_photo(upload, current_filename: str | None = None) -> str | None:
|
||||
if not upload or not upload.filename:
|
||||
return current_filename
|
||||
|
||||
if not allowed_file(upload.filename):
|
||||
if not allowed_image_file(upload.filename):
|
||||
raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.")
|
||||
|
||||
original_name = secure_filename(upload.filename)
|
||||
extension = original_name.rsplit(".", 1)[1].lower()
|
||||
filename = f"{uuid.uuid4().hex}.{extension}"
|
||||
destination = Path(current_app.config["UPLOAD_FOLDER"]) / filename
|
||||
upload.save(destination)
|
||||
|
||||
if current_filename:
|
||||
old_path = Path(current_app.config["UPLOAD_FOLDER"]) / current_filename
|
||||
if old_path.exists():
|
||||
old_path.unlink()
|
||||
|
||||
return filename
|
||||
if not upload_file_size_ok(upload, current_app.config["MAX_CONTENT_LENGTH"]):
|
||||
raise ValueError("Das Bild ist gerade zu groß. Ein etwas kleineres Foto hilft hier am besten.")
|
||||
return save_photo_with_variants(upload, current_app.config["UPLOAD_FOLDER"], current_filename=current_filename)
|
||||
|
||||
|
||||
def user_display_name(display_name: str | None, username: str | None) -> str:
|
||||
@@ -294,6 +311,8 @@ 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"]
|
||||
@@ -511,6 +530,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,
|
||||
@@ -599,6 +656,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")),
|
||||
@@ -608,6 +666,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(),
|
||||
}
|
||||
)
|
||||
@@ -618,9 +677,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(),
|
||||
@@ -629,6 +688,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"],
|
||||
@@ -1156,60 +1216,134 @@ def format_item_names(items: list[dict], limit: int = 3) -> str:
|
||||
return ", ".join(item["name"] for item in items[:limit])
|
||||
|
||||
|
||||
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
|
||||
home_foods = fetch_items(kind="food", availability="home", daypart_id=daypart_id)
|
||||
home_food_ids = {item["id"] for item in home_foods}
|
||||
def item_matches_daypart(item: dict, daypart_id: int | None) -> bool:
|
||||
if daypart_id is None:
|
||||
return True
|
||||
dayparts_meta = item.get("dayparts_meta") or []
|
||||
if not dayparts_meta:
|
||||
return True
|
||||
return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta)
|
||||
|
||||
|
||||
def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]:
|
||||
return tuple(sorted({int(component_id) for component_id in component_ids}))
|
||||
|
||||
|
||||
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:
|
||||
return f"{names[0]} mit {', '.join(names[1:])}"
|
||||
if len(names) >= 2:
|
||||
return f"{names[0]} mit {', '.join(names[1:])}"
|
||||
return names[0]
|
||||
|
||||
|
||||
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", "dairy", "seeds"),
|
||||
("carb", "fruit", "dairy"),
|
||||
]
|
||||
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", "dairy", "seeds"): "Lässt sich gut für einen Snack vormerken",
|
||||
("carb", "fruit", "dairy"): "Zuhause gut kombinierbar",
|
||||
}
|
||||
else:
|
||||
target_patterns = [
|
||||
("protein", "carb", "veg"),
|
||||
("protein", "carb"),
|
||||
]
|
||||
reasons = {
|
||||
("protein", "carb", "veg"): "Zuhause als vollständige Mahlzeit möglich",
|
||||
("protein", "carb"): "Lässt sich leicht ergänzen",
|
||||
}
|
||||
|
||||
suggestions: list[dict] = []
|
||||
meals = fetch_items(kind="meal", daypart_id=daypart_id)
|
||||
seen_signatures: set[tuple[int, ...]] = set()
|
||||
|
||||
for pattern in target_patterns:
|
||||
groups = [builder_groups.get(builder_key, []) for builder_key in pattern]
|
||||
if any(not group for group in groups):
|
||||
continue
|
||||
for combo in product(*groups):
|
||||
signature = normalized_component_signature([item["id"] for item in combo])
|
||||
if len(signature) != len(pattern) 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"),
|
||||
"component_ids": [item["id"] for item in combo_items],
|
||||
"existing_item_id": None,
|
||||
}
|
||||
)
|
||||
if len(suggestions) >= limit:
|
||||
return suggestions
|
||||
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 "")
|
||||
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}
|
||||
|
||||
suggestions: list[dict] = []
|
||||
seen_signatures: set[tuple[int, ...]] = set()
|
||||
meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)]
|
||||
for meal in meals:
|
||||
if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]):
|
||||
signature = normalized_component_signature(meal["component_ids"])
|
||||
if signature in seen_signatures:
|
||||
continue
|
||||
seen_signatures.add(signature)
|
||||
component_items = [home_food_map[component_id] for component_id in meal["component_ids"] if component_id in home_food_map]
|
||||
suggestions.append(
|
||||
{
|
||||
"title": meal["name"],
|
||||
"reason": "Zuhause vorhanden",
|
||||
"component_ids": meal["component_ids"],
|
||||
"existing_item_id": meal["id"],
|
||||
"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 "")
|
||||
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
||||
if builder_groups["carb"] and builder_groups["dairy"]:
|
||||
combo = [builder_groups["carb"][0], builder_groups["dairy"][0]]
|
||||
if builder_groups["fruit"]:
|
||||
combo.append(builder_groups["fruit"][0])
|
||||
if builder_groups["nuts"]:
|
||||
combo.append(builder_groups["nuts"][0])
|
||||
suggestions.append(
|
||||
{
|
||||
"title": " mit ".join([combo[0]["name"], combo[1]["name"]]) if len(combo) == 2 else f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}",
|
||||
"reason": "Lässt sich gut ergänzen",
|
||||
"component_ids": [item["id"] for item in combo],
|
||||
"existing_item_id": None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
if builder_groups["protein"] and builder_groups["carb"]:
|
||||
combo = [builder_groups["protein"][0], builder_groups["carb"][0]]
|
||||
if builder_groups["veg"]:
|
||||
combo.append(builder_groups["veg"][0])
|
||||
suggestions.append(
|
||||
{
|
||||
"title": f"{combo[0]['name']} mit {', '.join(item['name'] for item in combo[1:])}",
|
||||
"reason": "Aus Zuhause zusammengesetzt",
|
||||
"component_ids": [item["id"] for item in combo],
|
||||
"existing_item_id": None,
|
||||
}
|
||||
)
|
||||
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:
|
||||
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"])
|
||||
@@ -1239,6 +1373,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.",
|
||||
@@ -1314,7 +1452,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?")
|
||||
|
||||
@@ -1336,6 +1474,77 @@ def build_dashboard_hints(today: date) -> list[str]:
|
||||
return hints[:4]
|
||||
|
||||
|
||||
def build_setup_checklist(today: date) -> list[dict]:
|
||||
total_items = int(
|
||||
get_db().execute(
|
||||
f"SELECT COUNT(*) AS count FROM items WHERE {visible_clause('items')}",
|
||||
visible_params(),
|
||||
).fetchone()["count"]
|
||||
)
|
||||
meal_count = int(
|
||||
get_db().execute(
|
||||
f"SELECT COUNT(*) AS count FROM items WHERE kind = 'meal' AND {visible_clause('items')}",
|
||||
visible_params(),
|
||||
).fetchone()["count"]
|
||||
)
|
||||
week_end = today + timedelta(days=6)
|
||||
plan_count = int(
|
||||
get_db().execute(
|
||||
f"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM plan_entries
|
||||
WHERE plan_date BETWEEN ? AND ? AND {visible_clause('plan_entries')}
|
||||
""",
|
||||
[today.isoformat(), week_end.isoformat(), *visible_params()],
|
||||
).fetchone()["count"]
|
||||
)
|
||||
template_count = int(
|
||||
get_db().execute(
|
||||
f"SELECT COUNT(*) AS count FROM day_templates WHERE {visible_clause('day_templates')}",
|
||||
visible_params(),
|
||||
).fetchone()["count"]
|
||||
)
|
||||
|
||||
checklist = []
|
||||
if total_items == 0:
|
||||
checklist.append(
|
||||
{
|
||||
"title": "Fang mit einem ersten Lebensmittel an",
|
||||
"text": "Ein kleines Frühstück, ein Snack oder etwas für zuhause reicht völlig für den Start.",
|
||||
"url": url_for("main.item_create", kind="food"),
|
||||
"label": "Lebensmittel anlegen",
|
||||
}
|
||||
)
|
||||
if total_items > 0 and meal_count == 0:
|
||||
checklist.append(
|
||||
{
|
||||
"title": "Lege eine erste Mahlzeitenidee an",
|
||||
"text": "Einfach zwei oder drei vertraute Dinge zusammenklicken und für später merken.",
|
||||
"url": url_for("main.item_create", kind="meal"),
|
||||
"label": "Mahlzeit anlegen",
|
||||
}
|
||||
)
|
||||
if plan_count == 0:
|
||||
checklist.append(
|
||||
{
|
||||
"title": "Plane einen ruhigen ersten Tag",
|
||||
"text": "Mit einem kleinen Eintrag für Frühstück oder Abendessen fühlt sich die Woche sofort greifbarer an.",
|
||||
"url": url_for("main.planner_day", date=today.isoformat()),
|
||||
"label": "Tag öffnen",
|
||||
}
|
||||
)
|
||||
if total_items > 0 and template_count == 0:
|
||||
checklist.append(
|
||||
{
|
||||
"title": "Merke dir einen gelungenen Tag als Vorlage",
|
||||
"text": "So wird Wiederverwendung später noch leichter.",
|
||||
"url": url_for("main.template_library"),
|
||||
"label": "Vorlagen ansehen",
|
||||
}
|
||||
)
|
||||
return checklist[:3]
|
||||
|
||||
|
||||
def build_day_hints(selected_date: date) -> list[str]:
|
||||
settings = get_user_settings()
|
||||
if not settings.get("reminders_enabled"):
|
||||
@@ -1448,7 +1657,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():
|
||||
@@ -1477,6 +1731,13 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
|
||||
"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",
|
||||
@@ -1963,6 +2224,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(),
|
||||
)
|
||||
@@ -1975,6 +2237,28 @@ def create_or_get_generated_meal(
|
||||
daypart_id: int,
|
||||
visibility: str,
|
||||
) -> int:
|
||||
normalized_ids = normalized_component_signature(component_ids)
|
||||
existing_meals = [
|
||||
item
|
||||
for item in fetch_items(kind="meal")
|
||||
if normalized_component_signature(item.get("component_ids", [])) == normalized_ids
|
||||
]
|
||||
if existing_meals:
|
||||
meal_id = int(existing_meals[0]["id"])
|
||||
current_dayparts = get_item_daypart_ids(meal_id)
|
||||
if daypart_id not in current_dayparts:
|
||||
sync_item_dayparts(meal_id, current_dayparts + [daypart_id])
|
||||
get_db().execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET updated_by = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(g.user["id"], meal_id),
|
||||
)
|
||||
get_db().commit()
|
||||
return meal_id
|
||||
|
||||
existing = get_db().execute(
|
||||
f"""
|
||||
SELECT items.id
|
||||
@@ -1986,7 +2270,21 @@ def create_or_get_generated_meal(
|
||||
[name, *visible_params()],
|
||||
).fetchone()
|
||||
if existing:
|
||||
return int(existing["id"])
|
||||
meal_id = int(existing["id"])
|
||||
sync_meal_components(meal_id, list(normalized_ids))
|
||||
current_dayparts = get_item_daypart_ids(meal_id)
|
||||
if daypart_id not in current_dayparts:
|
||||
sync_item_dayparts(meal_id, current_dayparts + [daypart_id])
|
||||
get_db().execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET updated_by = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(g.user["id"], meal_id),
|
||||
)
|
||||
get_db().commit()
|
||||
return meal_id
|
||||
|
||||
cursor = get_db().execute(
|
||||
"""
|
||||
@@ -2000,14 +2298,14 @@ def create_or_get_generated_meal(
|
||||
g.user["id"],
|
||||
visibility,
|
||||
name,
|
||||
"Kleines Essen",
|
||||
"Kleines Essen" if get_daypart_by_id(daypart_id)["slug"] in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} else "Warmes",
|
||||
g.user["id"],
|
||||
g.user["id"],
|
||||
),
|
||||
)
|
||||
meal_id = int(cursor.lastrowid)
|
||||
sync_item_dayparts(meal_id, [daypart_id])
|
||||
sync_meal_components(meal_id, component_ids)
|
||||
sync_meal_components(meal_id, list(normalized_ids))
|
||||
get_db().commit()
|
||||
return meal_id
|
||||
|
||||
@@ -2074,6 +2372,7 @@ def dashboard():
|
||||
upcoming_entries=fetch_upcoming_shopping_needs(limit=4),
|
||||
day_templates=fetch_day_templates()[:3],
|
||||
week_templates=fetch_week_templates()[:3],
|
||||
setup_checklist=build_setup_checklist(today),
|
||||
)
|
||||
|
||||
|
||||
@@ -2432,18 +2731,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 = ?,
|
||||
@@ -2457,12 +2762,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),
|
||||
@@ -2525,9 +2835,60 @@ def settings_view():
|
||||
push_subscription_count=int(push_subscription["count"]),
|
||||
push_ready=push_is_configured(),
|
||||
push_public_key_value=push_public_key(),
|
||||
restore_confirmation_text=RESTORE_CONFIRMATION_TEXT,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.get("/settings/backup/export")
|
||||
@login_required
|
||||
@admin_required
|
||||
def backup_export():
|
||||
archive_path, download_name = export_backup_archive(
|
||||
get_db(),
|
||||
current_app.config["UPLOAD_FOLDER"],
|
||||
current_app.config["APP_VERSION"],
|
||||
)
|
||||
|
||||
@after_this_request
|
||||
def cleanup_backup(response):
|
||||
Path(archive_path).unlink(missing_ok=True)
|
||||
return response
|
||||
|
||||
return send_file(
|
||||
archive_path,
|
||||
as_attachment=True,
|
||||
download_name=download_name,
|
||||
mimetype="application/zip",
|
||||
max_age=0,
|
||||
)
|
||||
|
||||
|
||||
@main_bp.post("/settings/backup/restore")
|
||||
@login_required
|
||||
@admin_required
|
||||
def backup_restore():
|
||||
confirmation = request.form.get("restore_confirmation", "").strip().upper()
|
||||
backup_file = request.files.get("backup_file")
|
||||
if confirmation != RESTORE_CONFIRMATION_TEXT:
|
||||
flash("Bitte die Bestätigung genau eintragen, bevor das Backup wiederhergestellt wird.", "error")
|
||||
return redirect(url_for("main.settings_view"))
|
||||
if not backup_file or not backup_file.filename:
|
||||
flash("Bitte zuerst eine Backup-Datei auswählen.", "error")
|
||||
return redirect(url_for("main.settings_view"))
|
||||
|
||||
try:
|
||||
metadata = restore_backup_archive(get_db(), current_app.config["UPLOAD_FOLDER"], backup_file)
|
||||
get_db().commit()
|
||||
except Exception as exc:
|
||||
get_db().rollback()
|
||||
flash(str(exc) or "Das Backup konnte gerade nicht wiederhergestellt werden.", "error")
|
||||
return redirect(url_for("main.settings_view"))
|
||||
|
||||
version_label = metadata.get("app_version") or "einer älteren Version"
|
||||
flash(f"Das Backup aus {version_label} wurde wiederhergestellt.", "success")
|
||||
return redirect(url_for("main.settings_view"))
|
||||
|
||||
|
||||
@main_bp.post("/push/subscribe")
|
||||
@login_required
|
||||
def push_subscribe():
|
||||
@@ -2624,6 +2985,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,
|
||||
@@ -2633,6 +2995,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": "",
|
||||
}
|
||||
|
||||
@@ -2671,9 +3034,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(),
|
||||
@@ -2683,6 +3046,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"],
|
||||
@@ -2714,6 +3078,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"],
|
||||
@@ -2723,6 +3088,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": "",
|
||||
}
|
||||
|
||||
@@ -2763,6 +3129,7 @@ def item_edit(item_id: int):
|
||||
UPDATE items
|
||||
SET name = ?,
|
||||
category = ?,
|
||||
energy_density = ?,
|
||||
note = ?,
|
||||
visibility = ?,
|
||||
target_user_id = ?,
|
||||
@@ -2774,6 +3141,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"],
|
||||
@@ -3096,14 +3464,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],
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
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, "label": "Frühstück"},
|
||||
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "label": "Mittagessen"},
|
||||
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "label": "Abendessen"},
|
||||
]
|
||||
|
||||
|
||||
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) -> bool:
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
delta = (now - target).total_seconds()
|
||||
return 0 <= delta < 180
|
||||
|
||||
|
||||
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"]):
|
||||
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)
|
||||
@@ -1,5 +1,11 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS households (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
@@ -44,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,
|
||||
@@ -74,6 +85,15 @@ 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 dayparts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
@@ -90,6 +110,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')),
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffd7be"/>
|
||||
<stop offset="100%" stop-color="#e39a63"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#b5dfc8"/>
|
||||
<stop offset="100%" stop-color="#72a98b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="18" fill="url(#bg)"/>
|
||||
<path d="M16 34c0-3 2.4-5.4 5.4-5.4h21.2c3 0 5.4 2.4 5.4 5.4 0 9.2-7.4 16.6-16.6 16.6h-.8C23.4 50.6 16 43.2 16 34z" fill="#fff9f4"/>
|
||||
<path d="M21 25c2.4-6.7 7.2-10.6 11-10.6S40.6 18.3 43 25" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="4"/>
|
||||
<path d="M40 12c5 .4 9 5 9 10.1 0 .5 0 1-.1 1.5-.8-.7-1.8-1.2-2.9-1.5-2.7-.8-4.3-3.3-4.2-6.1 0-1.3-.6-2.6-1.8-4z" fill="url(#leaf)"/>
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="3" width="58" height="58" rx="18" fill="#E3A06B"/>
|
||||
<path d="M16 36H48C46.9 44.2 40.0727 50 31.8 50H32.2C23.9273 50 17.1 44.2 16 36Z" fill="#FFF5EC"/>
|
||||
<path d="M31.9997 19C36.9702 19 40.9997 23.0781 40.9997 28.1081V29.3848C40.9997 32.7262 39.6098 35.9158 37.1535 38.1417L29.9949 44.6282H24.5605L32.4863 37.4468C34.0025 36.0726 34.8571 34.1103 34.8571 32.0653V28.1081C34.8571 26.551 33.5671 25.2162 31.9997 25.2162C30.4324 25.2162 29.1424 26.551 29.1424 28.1081V29.3848H23C23 23.0781 27.0295 19 31.9997 19Z" fill="#8C533B"/>
|
||||
<rect x="24" y="39" width="16" height="6" rx="3" fill="#8C533B"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 715 B |
@@ -1,21 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title">
|
||||
<title>Nouri</title>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffd7be"/>
|
||||
<stop offset="55%" stop-color="#f5b17a"/>
|
||||
<stop offset="100%" stop-color="#d58c57"/>
|
||||
<linearGradient id="nouriBg" x1="88" y1="72" x2="420" y2="440" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6C394"/>
|
||||
<stop offset="1" stop-color="#DE9862"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#b5dfc8"/>
|
||||
<stop offset="100%" stop-color="#70aa87"/>
|
||||
<linearGradient id="nouriBowl" x1="161" y1="176" x2="349" y2="339" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFF8F0"/>
|
||||
<stop offset="1" stop-color="#FDE7D5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="64" fill="url(#bg)"/>
|
||||
<circle cx="128" cy="128" r="96" fill="rgba(255,255,255,0.16)"/>
|
||||
<path d="M68 132c0-9.9 8.1-18 18-18h84c9.9 0 18 8.1 18 18 0 31.8-25.8 57.6-57.6 57.6h-4.8C93.8 189.6 68 163.8 68 132z" fill="#fff9f4"/>
|
||||
<path d="M84 105c7-21.3 24-34 44-34s37 12.7 44 34" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="14"/>
|
||||
<path d="M156 55c15 1 27 14.7 27 30.6 0 1.6-.1 3.1-.3 4.6-1.9-2.4-4.5-4.2-7.5-5.1-8-2.5-13.1-10.2-12.7-18.5.1-4.1-1.8-8-5.2-11.6z" fill="url(#leaf)"/>
|
||||
<path d="M129 143h41" stroke="#f0a46c" stroke-linecap="round" stroke-width="10"/>
|
||||
<path d="M92 96l-10 62" stroke="#fff9f4" stroke-linecap="round" stroke-width="10"/>
|
||||
<rect x="24" y="24" width="464" height="464" rx="122" fill="url(#nouriBg)"/>
|
||||
<rect x="48" y="48" width="416" height="416" rx="104" fill="white" fill-opacity="0.12"/>
|
||||
<path d="M152 232C152 175.667 197.667 130 254 130H258C315.438 130 362 176.562 362 234V242C362 299.438 315.438 346 258 346H254C197.667 346 152 300.333 152 244V232Z" fill="url(#nouriBowl)"/>
|
||||
<path d="M175 244C175 201.474 209.474 167 252 167H258C300.526 167 335 201.474 335 244V244C335 286.526 300.526 321 258 321H252C209.474 321 175 286.526 175 244V244Z" fill="#EFC39F" fill-opacity="0.22"/>
|
||||
<path d="M164 287H348C344.6 332.419 307.445 368 261.2 368H250.8C204.555 368 167.4 332.419 164 287Z" fill="#F7B37D"/>
|
||||
<path d="M198 287H314C311.53 314.638 288.347 336 260.2 336H251.8C223.653 336 200.47 314.638 198 287Z" fill="#A86244" fill-opacity="0.2"/>
|
||||
<path d="M255.999 171C271.513 171 284.246 183.574 284.246 199.27V205.573C284.246 216.441 279.591 226.794 271.487 233.994L246.435 256.256C241.017 261.07 237.906 268.003 237.906 275.283V295.246H212.906V275.283C212.906 260.722 219.129 246.854 229.965 237.227L255.016 214.966C257.705 212.578 259.246 209.146 259.246 205.573V199.27C259.246 197.28 257.651 195.6 255.999 195.6C254.347 195.6 252.752 197.28 252.752 199.27V205.091H227.752V199.27C227.752 183.574 240.485 171 255.999 171Z" fill="#8C533B"/>
|
||||
<rect x="226" y="280" width="63" height="25" rx="12.5" fill="#8C533B"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 805 B |
|
After Width: | Height: | Size: 7.9 KiB |
@@ -103,6 +103,17 @@ button,
|
||||
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
.button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent-strong) 78%, white 22%);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.button:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -152,13 +163,11 @@ button.secondary:hover,
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
position: static;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
padding: 1rem 1.2rem;
|
||||
margin-bottom: 1.15rem;
|
||||
background: var(--bg-elevated);
|
||||
@@ -168,6 +177,11 @@ button.secondary:hover,
|
||||
backdrop-filter: blur(26px) saturate(1.18);
|
||||
}
|
||||
|
||||
.desktop-header-main,
|
||||
.desktop-header-sub {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -213,7 +227,7 @@ h3,
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -241,6 +255,59 @@ h3,
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 1081px) {
|
||||
.site-header {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
column-gap: 1.5rem;
|
||||
row-gap: 0.9rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.desktop-header-main {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.desktop-header-sub {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
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 {
|
||||
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,
|
||||
@@ -328,6 +395,12 @@ h3,
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--surface) 86%, #fff 14%), color-mix(in srgb, var(--surface) 80%, #ffe5d2 20%));
|
||||
}
|
||||
|
||||
.hero h1,
|
||||
.page-intro h1,
|
||||
.panel h2 {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.45rem;
|
||||
text-transform: uppercase;
|
||||
@@ -370,6 +443,10 @@ h3 {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-panel {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.intro-pills,
|
||||
.chip-row {
|
||||
display: flex;
|
||||
@@ -599,6 +676,21 @@ h3 {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.setup-intro-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
margin: 1.1rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.setup-tip,
|
||||
.restore-warning {
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
|
||||
}
|
||||
|
||||
.stack-form,
|
||||
.stack-sections,
|
||||
.planner-day-stack,
|
||||
@@ -715,6 +807,7 @@ legend {
|
||||
width: min(220px, 100%);
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 16px 30px rgba(94, 68, 49, 0.12);
|
||||
}
|
||||
|
||||
.compact-form-panel {
|
||||
@@ -948,6 +1041,12 @@ legend {
|
||||
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
|
||||
}
|
||||
|
||||
.restore-warning strong,
|
||||
.setup-tip strong {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.card-link-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
@@ -969,6 +1068,16 @@ legend {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.menu-card-button {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.menu-card-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.roomy-row {
|
||||
padding: 1rem 1.2rem;
|
||||
}
|
||||
@@ -1191,6 +1300,10 @@ legend {
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.mobile-nav-stack {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-more-sheet {
|
||||
position: fixed;
|
||||
left: 0.75rem;
|
||||
@@ -1223,6 +1336,10 @@ legend {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.mobile-menu-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.mobile-sheet-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -1232,7 +1349,6 @@ legend {
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.site-header,
|
||||
.hero,
|
||||
.page-intro,
|
||||
.panel-head,
|
||||
@@ -1241,9 +1357,19 @@ legend {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
body.has-mobile-nav {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: static;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
margin: 0 0 1.15rem;
|
||||
}
|
||||
|
||||
.desktop-header-main,
|
||||
.desktop-header-sub {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
@@ -1273,12 +1399,10 @@ legend {
|
||||
}
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
top: 0.7rem;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
margin-bottom: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
@@ -1343,6 +1467,10 @@ legend {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.setup-intro-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1377,54 +1505,95 @@ legend {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav {
|
||||
.mobile-nav-stack {
|
||||
position: fixed;
|
||||
left: 0.75rem;
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
z-index: 20;
|
||||
z-index: 24;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 22px;
|
||||
background: var(--bg-elevated);
|
||||
background: color-mix(in srgb, var(--bg) 96%, #f6decb 4%);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(26px) saturate(1.15);
|
||||
}
|
||||
|
||||
.mobile-bottom-nav a {
|
||||
.mobile-nav-extension {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-nav-stack.is-open .mobile-nav-extension {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 0.28rem;
|
||||
padding: 0.55rem 0.35rem;
|
||||
border-radius: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.mobile-nav-extension,
|
||||
.mobile-sheet-links.mobile-menu-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mobile-extra-link,
|
||||
.mobile-extra-button,
|
||||
.mobile-bottom-nav a,
|
||||
.mobile-nav-button {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
display: grid;
|
||||
min-height: 3.95rem;
|
||||
padding: 0.55rem 0.2rem 0.5rem;
|
||||
text-align: center;
|
||||
gap: 0.28rem;
|
||||
padding: 0.55rem 0.35rem;
|
||||
border-radius: 16px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav a.active,
|
||||
.mobile-nav-button.is-open {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mobile-extra-link .ui-icon,
|
||||
.mobile-extra-button .ui-icon,
|
||||
.mobile-bottom-nav .ui-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mobile-extra-link span:last-child,
|
||||
.mobile-extra-button span:last-child,
|
||||
.mobile-bottom-nav a span:last-child,
|
||||
.mobile-nav-button span:last-child {
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.mobile-extra-form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav a {
|
||||
}
|
||||
|
||||
.mobile-nav-button {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mobile-bottom-nav a.active,
|
||||
.mobile-extra-link.active,
|
||||
.mobile-nav-button.is-open {
|
||||
background: var(--accent-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mobile-profile-link {
|
||||
@@ -1438,8 +1607,6 @@ legend {
|
||||
height: 2.15rem;
|
||||
}
|
||||
|
||||
.mobile-sheet-head,
|
||||
.mobile-sheet-actions,
|
||||
.week-template-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
(() => {
|
||||
const initMobileSheet = () => {
|
||||
const sheet = document.querySelector("[data-mobile-sheet]");
|
||||
const backdrop = document.querySelector("[data-mobile-sheet-backdrop]");
|
||||
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
||||
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
|
||||
const closeButtons = document.querySelectorAll("[data-mobile-sheet-close]");
|
||||
if (!sheet || !backdrop || !openButtons.length) return;
|
||||
if (!sheet || !navStack || !openButtons.length) return;
|
||||
|
||||
const closeSheet = () => {
|
||||
sheet.hidden = true;
|
||||
backdrop.hidden = true;
|
||||
document.body.classList.remove("sheet-open");
|
||||
navStack.classList.remove("is-open");
|
||||
openButtons.forEach((button) => button.classList.remove("is-open"));
|
||||
};
|
||||
|
||||
const openSheet = () => {
|
||||
sheet.hidden = false;
|
||||
backdrop.hidden = false;
|
||||
document.body.classList.add("sheet-open");
|
||||
navStack.classList.add("is-open");
|
||||
openButtons.forEach((button) => button.classList.add("is-open"));
|
||||
};
|
||||
|
||||
const toggleSheet = () => {
|
||||
if (sheet.hidden) {
|
||||
openSheet();
|
||||
} else {
|
||||
closeSheet();
|
||||
}
|
||||
};
|
||||
|
||||
openButtons.forEach((button) => {
|
||||
button.addEventListener("click", openSheet);
|
||||
button.addEventListener("click", toggleSheet);
|
||||
});
|
||||
closeButtons.forEach((button) => {
|
||||
button.addEventListener("click", closeSheet);
|
||||
});
|
||||
backdrop.addEventListener("click", closeSheet);
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
@@ -37,6 +38,9 @@
|
||||
sheet.querySelectorAll("a").forEach((link) => {
|
||||
link.addEventListener("click", closeSheet);
|
||||
});
|
||||
sheet.querySelectorAll("button[data-theme-toggle]").forEach((button) => {
|
||||
button.addEventListener("click", closeSheet);
|
||||
});
|
||||
};
|
||||
|
||||
const initFilterInputs = () => {
|
||||
@@ -47,12 +51,57 @@
|
||||
if (!container) return;
|
||||
|
||||
const items = Array.from(container.querySelectorAll("[data-filter-label]"));
|
||||
const filterGroups = Array.from(container.querySelectorAll("[data-filter-group]"));
|
||||
const resultLimit = Number.parseInt(input.getAttribute("data-filter-limit") || "", 10);
|
||||
const hasLimit = Number.isFinite(resultLimit) && resultLimit > 0;
|
||||
|
||||
const scoreItem = (label, term) => {
|
||||
if (label === term) return 0;
|
||||
if (label.startsWith(term)) return 1;
|
||||
if (label.split(/\s+/).some((part) => part.startsWith(term))) return 2;
|
||||
if (label.includes(term)) return 3;
|
||||
return 99;
|
||||
};
|
||||
|
||||
const syncGroups = () => {
|
||||
filterGroups.forEach((group) => {
|
||||
const visibleChildren = Array.from(group.querySelectorAll("[data-filter-label]")).some((item) => !item.hidden);
|
||||
const card = group.closest(".component-group, .template-list-card, .panel, .planner-subsection");
|
||||
if (card) {
|
||||
card.hidden = !visibleChildren;
|
||||
} else {
|
||||
group.hidden = !visibleChildren;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyFilter = () => {
|
||||
const term = input.value.trim().toLowerCase();
|
||||
if (!term) {
|
||||
items.forEach((item) => {
|
||||
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
|
||||
item.hidden = Boolean(term) && !haystack.includes(term);
|
||||
item.hidden = false;
|
||||
});
|
||||
syncGroups();
|
||||
return;
|
||||
}
|
||||
|
||||
const rankedMatches = items
|
||||
.map((item, index) => {
|
||||
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
|
||||
const score = scoreItem(haystack, term);
|
||||
return { item, index, score, matches: score < 99 };
|
||||
})
|
||||
.filter((entry) => entry.matches)
|
||||
.sort((left, right) => left.score - right.score || left.index - right.index);
|
||||
|
||||
const allowedItems = new Set(
|
||||
(hasLimit ? rankedMatches.slice(0, resultLimit) : rankedMatches).map((entry) => entry.item)
|
||||
);
|
||||
|
||||
items.forEach((item) => {
|
||||
item.hidden = !allowedItems.has(item);
|
||||
});
|
||||
syncGroups();
|
||||
};
|
||||
|
||||
input.addEventListener("input", applyFilter);
|
||||
|
||||
@@ -6,9 +6,16 @@
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "minimal-ui"],
|
||||
"background_color": "#fff6ef",
|
||||
"theme_color": "#efab72",
|
||||
"theme_color": "#de9862",
|
||||
"categories": ["food", "lifestyle", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/brand/pwa-180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/brand/pwa-192.png",
|
||||
"sizes": "192x192",
|
||||
@@ -18,6 +25,12 @@
|
||||
"src": "/static/brand/pwa-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/brand/pwa-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nouri offline</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #fff6ef;
|
||||
--surface: rgba(255, 255, 255, 0.92);
|
||||
--line: rgba(126, 104, 85, 0.14);
|
||||
--text: #352d2b;
|
||||
--muted: #7d7069;
|
||||
--accent: #de9862;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 205, 174, 0.42), transparent 24rem),
|
||||
radial-gradient(circle at 90% 8%, rgba(190, 226, 203, 0.34), transparent 24rem),
|
||||
linear-gradient(180deg, var(--bg), #fdf0e6);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(28rem, 100%);
|
||||
padding: 1.4rem;
|
||||
border-radius: 24px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 22px 48px rgba(125, 92, 68, 0.12);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.35rem 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.82rem 1.1rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>Nouri ist gerade kurz offline</h1>
|
||||
<p>Die App bleibt da und versucht es gleich wieder. Sobald die Verbindung zurück ist, kannst du normal weitermachen.</p>
|
||||
<p class="muted">Ein Teil der Oberfläche ist schon lokal verfügbar. Für aktuelle Haushaltsdaten braucht Nouri aber wieder eine Verbindung.</p>
|
||||
<a href="/">Erneut versuchen</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,19 +1,35 @@
|
||||
const CACHE_NAME = "nouri-v0-5-0";
|
||||
const APP_SHELL = [
|
||||
"/",
|
||||
const CACHE_NAME = "nouri-v1-0-0";
|
||||
const OFFLINE_URL = "/static/pwa/offline.html";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/css/styles.css",
|
||||
"/static/js/theme.js",
|
||||
"/static/js/ui.js",
|
||||
"/static/js/planner.js",
|
||||
"/static/js/pwa.js",
|
||||
"/static/brand/pwa-180.png",
|
||||
"/static/brand/pwa-192.png",
|
||||
"/static/brand/pwa-512.png",
|
||||
"/static/brand/pwa-maskable-512.png",
|
||||
"/static/brand/pwa-badge.png",
|
||||
"/static/brand/favicon.svg",
|
||||
"/app.webmanifest",
|
||||
OFFLINE_URL,
|
||||
];
|
||||
|
||||
const cacheFirst = async (request) => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response && response.ok && response.type === "basic") {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).then(() => self.skipWaiting())
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,19 +43,34 @@ self.addEventListener("activate", (event) => {
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
|
||||
const requestUrl = new URL(event.request.url);
|
||||
const isSameOrigin = requestUrl.origin === self.location.origin;
|
||||
const isStaticAsset = isSameOrigin && (
|
||||
requestUrl.pathname.startsWith("/static/")
|
||||
|| requestUrl.pathname === "/app.webmanifest"
|
||||
|| requestUrl.pathname === "/service-worker.js"
|
||||
);
|
||||
const isUpload = isSameOrigin && requestUrl.pathname.startsWith("/uploads/");
|
||||
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
if (cached) return cached;
|
||||
return fetch(event.request).then((response) => {
|
||||
if (!response || response.status !== 200 || response.type !== "basic") {
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
|
||||
return response;
|
||||
}
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
return response;
|
||||
});
|
||||
})
|
||||
.catch(async () => {
|
||||
return (await caches.match(event.request)) || caches.match(OFFLINE_URL);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStaticAsset || isUpload) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
|
||||
@@ -44,7 +44,12 @@
|
||||
<article class="item-card">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,22 @@
|
||||
<div class="auth-card">
|
||||
<p class="eyebrow">Erster Start</p>
|
||||
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
||||
<p class="lead">Danach könnt ihr Nouri gemeinsam nutzen. Persönliche und gemeinsame Einträge lassen sich später ruhig auseinanderhalten.</p>
|
||||
<p class="lead">Danach könnt ihr Nouri gemeinsam nutzen. Der erste Einstieg bleibt bewusst klein: Zugang anlegen, Einkaufstag festlegen, erste Lebensmittel sammeln und bei Bedarf später eine Tagesvorlage merken.</p>
|
||||
|
||||
<div class="setup-intro-grid">
|
||||
<div class="setup-tip">
|
||||
<strong>1. Ruhig starten</strong>
|
||||
<p class="muted">Ein erster Haushalt und ein Admin-Zugang reichen für den Anfang völlig.</p>
|
||||
</div>
|
||||
<div class="setup-tip">
|
||||
<strong>2. Alltag festhalten</strong>
|
||||
<p class="muted">Später könnt ihr Lebensmittel, Mahlzeitenideen und Planungen gemeinsam nutzen.</p>
|
||||
</div>
|
||||
<div class="setup-tip">
|
||||
<strong>3. Als App nutzen</strong>
|
||||
<p class="muted">Auf dem iPhone lässt sich Nouri später über Safari zum Home-Bildschirm hinzufügen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="stack-form">
|
||||
{{ csrf_input() }}
|
||||
|
||||
@@ -4,27 +4,30 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Nouri{% endblock %}</title>
|
||||
<meta name="theme-color" content="#efab72">
|
||||
<meta name="theme-color" content="#de9862">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Nouri">
|
||||
<meta name="application-name" content="Nouri">
|
||||
<meta name="csrf-token" content="{{ csrf_token_value }}">
|
||||
<meta name="nouri-push-public-key" content="{{ push_public_key }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='brand/favicon.svg') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/pwa-192.png') }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset_url('brand/favicon.svg') }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset_url('brand/pwa-180.png') }}">
|
||||
<link rel="manifest" href="{{ url_for('webmanifest') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/ui.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/pwa.js') }}"></script>
|
||||
<link rel="stylesheet" href="{{ asset_url('css/styles.css') }}">
|
||||
<script defer src="{{ asset_url('js/theme.js') }}"></script>
|
||||
<script defer src="{{ asset_url('js/planner.js') }}"></script>
|
||||
<script defer src="{{ asset_url('js/ui.js') }}"></script>
|
||||
<script defer src="{{ asset_url('js/pwa.js') }}"></script>
|
||||
</head>
|
||||
<body class="{% if g.user %}has-mobile-nav{% endif %}">
|
||||
<div class="page-shell">
|
||||
<header class="site-header">
|
||||
<div class="desktop-header-main">
|
||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||
<span class="brand-mark">
|
||||
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
|
||||
<img src="{{ asset_url('brand/nouri-icon.svg') }}" alt="">
|
||||
</span>
|
||||
<span class="brand-copy">
|
||||
<strong>Nouri</strong>
|
||||
@@ -44,9 +47,13 @@
|
||||
<a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></span></a>
|
||||
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if g.user %}
|
||||
<div class="desktop-header-sub">
|
||||
<div class="header-actions desktop-actions">
|
||||
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</button>
|
||||
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Hell</button>
|
||||
<a class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a>
|
||||
<a class="user-chip" href="{{ url_for('auth.profile') }}">
|
||||
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
|
||||
@@ -60,6 +67,7 @@
|
||||
<button class="ghost-button" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="mobile-profile-link ghost-button" type="button" data-mobile-sheet-open aria-label="Mehr öffnen">
|
||||
<span class="mobile-profile-avatar">{{ (g.user.display_name or g.user.username or 'N')[:1]|upper }}</span>
|
||||
@@ -93,36 +101,31 @@
|
||||
</div>
|
||||
|
||||
{% if g.user %}
|
||||
<div class="mobile-sheet-backdrop" data-mobile-sheet-backdrop hidden></div>
|
||||
<aside class="mobile-more-sheet" data-mobile-sheet hidden aria-label="Mehr">
|
||||
<div class="mobile-sheet-head">
|
||||
<div>
|
||||
<strong>{{ g.user.display_name or g.user.username }}</strong>
|
||||
<small>{{ role_labels[g.user.role] }}</small>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" data-mobile-sheet-close>Schließen</button>
|
||||
</div>
|
||||
<nav class="mobile-sheet-links card-link-grid">
|
||||
<a class="menu-card" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
|
||||
<a class="menu-card" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
|
||||
<a class="menu-card" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
|
||||
<a class="menu-card" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
|
||||
<a class="menu-card" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
|
||||
<a class="menu-card" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
|
||||
<a class="menu-card" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
|
||||
<div class="mobile-nav-stack" data-mobile-nav-stack>
|
||||
<nav class="mobile-nav-extension" data-mobile-sheet hidden aria-label="Mehr Navigation">
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
|
||||
{% if g.user.role == 'admin' %}
|
||||
<a class="menu-card" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
||||
<a class="menu-card" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="mobile-sheet-actions">
|
||||
<button class="ghost-button" type="button" data-theme-toggle>Modus wechseln</button>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle>
|
||||
<span class="ui-icon icon-mobile-screen-button"></span>
|
||||
<span>Modus</span>
|
||||
</button>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Abmelden</button>
|
||||
<button class="mobile-extra-link mobile-extra-button" type="submit">
|
||||
<span class="ui-icon icon-ellipsis"></span>
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
</nav>
|
||||
|
||||
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
|
||||
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
|
||||
@@ -146,6 +149,7 @@
|
||||
<span>Mehr</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if setup_checklist %}
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Gut anfangen</h2>
|
||||
</div>
|
||||
<div class="more-link-grid">
|
||||
{% for step in setup_checklist %}
|
||||
<a class="more-link-card" href="{{ step.url }}">
|
||||
<strong>{{ step.title }}</strong>
|
||||
<small>{{ step.text }}</small>
|
||||
<span class="chip">{{ step.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="stats-grid">
|
||||
<article class="stat-card">
|
||||
<span>Zuhause</span>
|
||||
|
||||
@@ -72,7 +72,12 @@
|
||||
<article class="item-card compact">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
@@ -67,7 +77,12 @@
|
||||
|
||||
{% if item and item.photo_filename %}
|
||||
<div class="inline-photo">
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'lg') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('detail') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -97,10 +112,12 @@
|
||||
placeholder="z. B. Reis, Banane, Joghurt"
|
||||
data-filter-input
|
||||
data-filter-target="#meal-components-list"
|
||||
data-filter-limit="3"
|
||||
>
|
||||
</label>
|
||||
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
|
||||
</div>
|
||||
<p class="helper-text">Während der Suche zeigt Nouri nur die drei passendsten Lebensmittel, damit die Auswahl ruhig bleibt.</p>
|
||||
{% if food_groups %}
|
||||
<div class="stack-sections" id="meal-components-list">
|
||||
{% for group in food_groups %}
|
||||
@@ -142,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">
|
||||
|
||||
@@ -54,7 +54,12 @@
|
||||
<article class="item-card">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
@@ -143,7 +172,7 @@
|
||||
<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 %}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Optionen</p>
|
||||
<h1>Ruhige Einstellungen für Alltag, Einkauf und Erinnerungen</h1>
|
||||
<p class="lead">Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und ob Nouri sich wie eine App auf dem Home-Bildschirm verhalten soll.</p>
|
||||
<h1>Ruhige Einstellungen für Alltag, Sicherung und iPhone-Nutzung</h1>
|
||||
<p class="lead">Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und wie Nouri sich auf dem Home-Bildschirm oder beim Backup verhalten soll.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -33,23 +33,29 @@
|
||||
Erinnerung ungefähr um
|
||||
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Home-Bildschirm & Push</h2>
|
||||
<h2>Für den Homescreen</h2>
|
||||
</div>
|
||||
<div class="stack-sections">
|
||||
<div class="pwa-card">
|
||||
<strong>Als Web-App nutzen</strong>
|
||||
<p class="muted">Auf dem iPhone kannst du Nouri über Teilen → Zum Home-Bildschirm hinzufügen. Danach wirkt die App deutlich app-näher.</p>
|
||||
<strong>Auf dem iPhone installieren</strong>
|
||||
<p class="muted">Öffne Nouri in Safari, tippe auf Teilen und dann auf <em>Zum Home-Bildschirm</em>. Danach startet Nouri deutlich app-näher und ruhiger.</p>
|
||||
</div>
|
||||
<div class="pwa-card">
|
||||
<strong>Offline etwas stabiler</strong>
|
||||
<p class="muted">Die wichtigsten Oberflächen und Brand-Dateien bleiben lokal greifbar. Wenn das Netz kurz weg ist, wirkt die App dadurch stabiler und klarer.</p>
|
||||
</div>
|
||||
<div class="pwa-card">
|
||||
<strong>Push-Mitteilungen</strong>
|
||||
{% if push_ready %}
|
||||
<p class="muted">Push ist vorbereitet. Du kannst es auf diesem Gerät freigeben und später testweise 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>
|
||||
@@ -61,7 +67,10 @@
|
||||
</form>
|
||||
<small class="helper-text">{{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %}</small>
|
||||
{% else %}
|
||||
<p class="muted">Push wird sichtbar, sobald VAPID-Schlüssel für die App gesetzt sind.</p>
|
||||
<p class="muted">Push wird sichtbar, sobald VAPID-Schlüssel für diese App gesetzt sind.</p>
|
||||
{% if push_public_key_value %}
|
||||
<small class="helper-text">Öffentlicher Schlüssel erkannt, privater Schlüssel fehlt noch.</small>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,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>
|
||||
@@ -103,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>
|
||||
@@ -122,4 +146,45 @@
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if is_admin() %}
|
||||
<section class="two-column">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Backup exportieren</h2>
|
||||
</div>
|
||||
<div class="stack-sections">
|
||||
<div class="pwa-card">
|
||||
<strong>Komplettes App-Backup</strong>
|
||||
<p class="muted">Das ZIP enthält Nutzer, Einstellungen, Lebensmittel, Mahlzeiten, Vorlagen, Planungen, Einkaufsdaten und hochgeladene Bilder.</p>
|
||||
<a class="button" href="{{ url_for('main.backup_export') }}">Backup herunterladen</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Backup wiederherstellen</h2>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('main.backup_restore') }}" class="stack-form" enctype="multipart/form-data">
|
||||
{{ csrf_input() }}
|
||||
<div class="restore-warning">
|
||||
<strong>Nur bewusst verwenden</strong>
|
||||
<p class="muted">Die Wiederherstellung ersetzt den aktuellen Datenstand dieses Haushalts. Vorher am besten selbst noch ein frisches Backup herunterladen.</p>
|
||||
</div>
|
||||
<label>
|
||||
Backup-Datei
|
||||
<input type="file" name="backup_file" accept=".zip" required>
|
||||
</label>
|
||||
<label>
|
||||
Zur Bestätigung bitte {{ restore_confirmation_text }} eintragen
|
||||
<input type="text" name="restore_confirmation" placeholder="{{ restore_confirmation_text }}" required>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Backup wiederherstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
Flask==3.1.1
|
||||
gunicorn==23.0.0
|
||||
pywebpush==2.3.0
|
||||
Pillow==11.2.1; python_version < "3.14"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BRAND_DIR = ROOT / "nouri" / "static" / "brand"
|
||||
|
||||
BG_TOP = "#F6C394"
|
||||
BG_BOTTOM = "#DE9862"
|
||||
BOWL = "#FFF7EF"
|
||||
STROKE = "#8C533B"
|
||||
|
||||
|
||||
def rounded_gradient_icon(size: int, *, maskable: bool = False) -> Image.Image:
|
||||
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
radius = int(size * (0.28 if maskable else 0.24))
|
||||
|
||||
for y in range(size):
|
||||
blend = y / max(size - 1, 1)
|
||||
r1, g1, b1 = ImageColorTuple(BG_TOP)
|
||||
r2, g2, b2 = ImageColorTuple(BG_BOTTOM)
|
||||
color = (
|
||||
int(r1 + (r2 - r1) * blend),
|
||||
int(g1 + (g2 - g1) * blend),
|
||||
int(b1 + (b2 - b1) * blend),
|
||||
255,
|
||||
)
|
||||
draw.line((0, y, size, y), fill=color)
|
||||
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
|
||||
image.putalpha(mask)
|
||||
|
||||
inset = int(size * (0.11 if maskable else 0.13))
|
||||
inner = [inset, inset, size - inset, size - inset]
|
||||
draw.rounded_rectangle(inner, radius=int(radius * 0.84), outline=(255, 255, 255, 54), width=max(2, size // 80))
|
||||
|
||||
bowl_top = int(size * 0.24)
|
||||
bowl_left = int(size * 0.27)
|
||||
bowl_right = int(size * 0.73)
|
||||
bowl_bottom = int(size * 0.68)
|
||||
draw.rounded_rectangle(
|
||||
(bowl_left, bowl_top, bowl_right, bowl_bottom),
|
||||
radius=int(size * 0.16),
|
||||
fill=BOWL,
|
||||
)
|
||||
draw.rounded_rectangle(
|
||||
(int(size * 0.31), int(size * 0.31), int(size * 0.69), int(size * 0.60)),
|
||||
radius=int(size * 0.12),
|
||||
fill=(239, 195, 159, 64),
|
||||
)
|
||||
|
||||
bowl_curve_top = int(size * 0.56)
|
||||
draw.pieslice(
|
||||
(int(size * 0.24), bowl_curve_top, int(size * 0.76), int(size * 0.84)),
|
||||
start=0,
|
||||
end=180,
|
||||
fill=(247, 179, 125, 255),
|
||||
)
|
||||
|
||||
line_width = max(4, size // 26)
|
||||
steam = [
|
||||
(0.50, 0.31),
|
||||
(0.56, 0.31),
|
||||
(0.56, 0.40),
|
||||
(0.45, 0.50),
|
||||
(0.45, 0.58),
|
||||
]
|
||||
draw.line([(int(size * x), int(size * y)) for x, y in steam], fill=STROKE, width=line_width, joint="curve")
|
||||
draw.line(
|
||||
[(int(size * 0.45), int(size * 0.58)), (int(size * 0.56), int(size * 0.58))],
|
||||
fill=STROKE,
|
||||
width=line_width,
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def ImageColorTuple(hex_color: str) -> tuple[int, int, int]:
|
||||
hex_color = hex_color.lstrip("#")
|
||||
return tuple(int(hex_color[index:index + 2], 16) for index in (0, 2, 4))
|
||||
|
||||
|
||||
def badge_icon(size: int) -> Image.Image:
|
||||
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=size // 3, fill=BG_BOTTOM)
|
||||
draw.ellipse((size * 0.18, size * 0.18, size * 0.82, size * 0.82), fill=BOWL)
|
||||
draw.rectangle((size * 0.34, size * 0.52, size * 0.66, size * 0.63), fill=STROKE)
|
||||
return image
|
||||
|
||||
|
||||
def save_assets() -> None:
|
||||
BRAND_DIR.mkdir(parents=True, exist_ok=True)
|
||||
rounded_gradient_icon(180).save(BRAND_DIR / "pwa-180.png")
|
||||
rounded_gradient_icon(192).save(BRAND_DIR / "pwa-192.png")
|
||||
rounded_gradient_icon(512).save(BRAND_DIR / "pwa-512.png")
|
||||
rounded_gradient_icon(512, maskable=True).save(BRAND_DIR / "pwa-maskable-512.png")
|
||||
badge_icon(96).save(BRAND_DIR / "pwa-badge.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
save_assets()
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from py_vapid import Vapid01, b64urlencode
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
|
||||
def main() -> None:
|
||||
vapid = Vapid01()
|
||||
vapid.generate_keys()
|
||||
|
||||
public_key = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
)
|
||||
private_value = vapid.private_key.private_numbers().private_value
|
||||
private_key = b64urlencode(private_value.to_bytes(32, "big"))
|
||||
|
||||
print(f"NOURI_VAPID_PUBLIC_KEY={public_key}")
|
||||
print(f"NOURI_VAPID_PRIVATE_KEY={private_key}")
|
||||
print("NOURI_VAPID_SUBJECT=mailto:mail@hnz.io")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -1,14 +1,23 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}"
|
||||
mkdir -p "${NOURI_DATA_DIR}"
|
||||
touch "${NOURI_DATA_DIR}/nouri.sqlite3"
|
||||
mkdir -p "${NOURI_DATA_DIR}/uploads"
|
||||
mkdir -p /app/data/uploads
|
||||
|
||||
exec gunicorn \
|
||||
--bind 0.0.0.0:8000 \
|
||||
--workers 2 \
|
||||
--threads 4 \
|
||||
--timeout 60 \
|
||||
wsgi:app
|
||||
# Vorhandene lokale SQLite-Datei beim allerersten Start übernehmen
|
||||
if [ ! -f /app/data/nouri.sqlite3 ] && [ -f /app/bootstrap-data/nouri.sqlite3 ]; then
|
||||
cp /app/bootstrap-data/nouri.sqlite3 /app/data/nouri.sqlite3
|
||||
fi
|
||||
|
||||
# Vorhandene Uploads beim allerersten Start übernehmen
|
||||
if [ -d /app/bootstrap-data/uploads ] && [ -z "$(ls -A /app/data/uploads 2>/dev/null || true)" ]; then
|
||||
cp -a /app/bootstrap-data/uploads/. /app/data/uploads/
|
||||
fi
|
||||
|
||||
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 2 --threads 4 wsgi:app
|
||||
|
||||