diff --git a/CloudronManifest.json b/CloudronManifest.json index 43aa619..b130119 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "0.6.0", - "upstreamVersion": "0.6.0", + "version": "1.0.0", + "upstreamVersion": "1.0.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/README.md b/README.md index fac8afb..72ca801 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. -## Merkmale in Version 0.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 --server --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=` +- `NOURI_VAPID_PUBLIC_KEY=` +- `NOURI_VAPID_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 \ + NOURI_VAPID_PUBLIC_KEY='...' \ + NOURI_VAPID_PRIVATE_KEY='...' \ + NOURI_VAPID_SUBJECT='mailto:mail@hnz.io' +``` + +Danach die App neu starten: + +```bash +cloudron restart --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 diff --git a/RELEASE_NOTES_1.0.0.md b/RELEASE_NOTES_1.0.0.md new file mode 100644 index 0000000..b8048a4 --- /dev/null +++ b/RELEASE_NOTES_1.0.0.md @@ -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. diff --git a/nouri/__init__.py b/nouri/__init__.py index 9d22d90..26cd33a 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -16,10 +16,14 @@ from .constants import ( BUILDER_OPTIONS, DAYPARTS, DEFAULT_CATEGORIES, + ENERGY_DENSITY_LABELS, + ENERGY_DENSITY_OPTIONS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, NOTIFICATION_CHANNEL_OPTIONS, ROLE_LABELS, + SUGGESTION_STYLE_LABELS, + SUGGESTION_STYLE_OPTIONS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, WEEKDAY_OPTIONS, @@ -69,7 +73,9 @@ def create_app() -> Flask: PERMANENT_SESSION_LIFETIME=timedelta(days=30), SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", - APP_VERSION="0.6.0", + SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1", + APP_VERSION="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"), @@ -97,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, @@ -159,6 +169,9 @@ def create_app() -> Flask: content_type = response.headers.get("Content-Type", "") if content_type.startswith("text/html"): response.headers.setdefault("Cache-Control", "no-store, max-age=0") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("Referrer-Policy", "same-origin") + response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") return response @app.errorhandler(413) diff --git a/nouri/auth.py b/nouri/auth.py index d7fa0fe..52ff0b0 100644 --- a/nouri/auth.py +++ b/nouri/auth.py @@ -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 diff --git a/nouri/constants.py b/nouri/constants.py index 039f349..46684bb 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -38,7 +38,8 @@ BUILDER_LABELS = { "protein": "Proteinquelle", "carb": "Kohlenhydratquelle", "veg": "Gemüse / Ballaststoffquelle", - "nuts": "Nüsse / Samen", + "nuts": "Nüsse", + "seeds": "Saaten", "fruit": "Obst", "dairy": "Milchprodukt", "neutral": "Neutral / sonstiges", @@ -48,7 +49,8 @@ BUILDER_DESCRIPTIONS = { "protein": "Passt eher zu sättigenden Eiweißquellen.", "carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.", "veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.", - "nuts": "Passt eher zu Nüssen oder Samen.", + "nuts": "Passt eher zu Nüssen.", + "seeds": "Passt eher zu Saaten.", "fruit": "Passt eher zu Obst.", "dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.", "neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.", @@ -56,6 +58,30 @@ BUILDER_DESCRIPTIONS = { BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()] +ENERGY_DENSITY_OPTIONS = [ + ("low", "Eher leicht"), + ("neutral", "Neutral"), + ("high", "Eher gehaltvoll"), +] + +ENERGY_DENSITY_LABELS = { + "low": "Eher leicht", + "neutral": "Neutral", + "high": "Eher gehaltvoll", +} + +SUGGESTION_STYLE_OPTIONS = [ + ("balanced", "Eher ausgewogen"), + ("fitness", "Fitness"), + ("protein", "Proteinbetont"), +] + +SUGGESTION_STYLE_LABELS = { + "balanced": "Eher ausgewogen", + "fitness": "Fitness", + "protein": "Proteinbetont", +} + WEEKDAY_OPTIONS = [ (0, "Montag"), (1, "Dienstag"), diff --git a/nouri/db.py b/nouri/db.py index 001055b..efbdd69 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -10,7 +10,7 @@ from werkzeug.security import generate_password_hash from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS -CURRENT_SCHEMA_VERSION = "0.6.0" +CURRENT_SCHEMA_VERSION = "1.0.0" def get_db() -> sqlite3.Connection: @@ -127,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") @@ -148,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, @@ -184,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 ( @@ -216,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( @@ -332,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( @@ -378,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( """ diff --git a/nouri/main.py b/nouri/main.py index 88a3dd7..c9a9044 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -27,10 +27,14 @@ from .constants import ( DEFAULT_CATEGORY_BUILDERS, DAY_TEMPLATE_NAME_SUGGESTIONS, DEFAULT_CATEGORIES, + ENERGY_DENSITY_LABELS, + ENERGY_DENSITY_OPTIONS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS, ITEM_SET_NAME_SUGGESTIONS, NOTIFICATION_CHANNEL_OPTIONS, + SUGGESTION_STYLE_LABELS, + SUGGESTION_STYLE_OPTIONS, VISIBILITY_DESCRIPTIONS, VISIBILITY_LABELS, WEEKDAY_OPTIONS, @@ -190,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", @@ -200,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 @@ -220,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 = ? " @@ -284,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"] @@ -501,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, @@ -589,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")), @@ -598,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(), } ) @@ -608,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(), @@ -619,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"], @@ -1178,11 +1248,13 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li 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: @@ -1222,12 +1294,15 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li 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() @@ -1238,26 +1313,37 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) 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 "") 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"]) @@ -1287,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.", @@ -1362,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?") @@ -1567,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(): @@ -1596,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", @@ -2082,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(), ) @@ -2588,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 = ?, @@ -2613,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), @@ -2831,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, @@ -2840,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": "", } @@ -2878,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(), @@ -2890,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"], @@ -2921,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"], @@ -2930,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": "", } @@ -2970,6 +3129,7 @@ def item_edit(item_id: int): UPDATE items SET name = ?, category = ?, + energy_density = ?, note = ?, visibility = ?, target_user_id = ?, @@ -2981,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"], @@ -3303,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], diff --git a/nouri/reminders.py b/nouri/reminders.py new file mode 100644 index 0000000..92cc4a0 --- /dev/null +++ b/nouri/reminders.py @@ -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) diff --git a/nouri/schema.sql b/nouri/schema.sql index d539257..b3202d8 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -50,12 +50,17 @@ CREATE TABLE IF NOT EXISTS user_settings ( reminders_enabled INTEGER NOT NULL DEFAULT 1, push_enabled INTEGER NOT NULL DEFAULT 0, notification_channel TEXT NOT NULL DEFAULT 'in_app', + suggestion_style TEXT NOT NULL DEFAULT 'balanced', + energy_preference TEXT NOT NULL DEFAULT 'neutral', remind_before_shopping INTEGER NOT NULL DEFAULT 1, remind_on_shopping_day INTEGER NOT NULL DEFAULT 1, show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1, show_planned_not_shopped INTEGER NOT NULL DEFAULT 1, remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1, remind_week_if_sparse INTEGER NOT NULL DEFAULT 1, + push_missing_breakfast INTEGER NOT NULL DEFAULT 0, + push_missing_lunch INTEGER NOT NULL DEFAULT 0, + push_missing_dinner INTEGER NOT NULL DEFAULT 0, suggest_home_for_today INTEGER NOT NULL DEFAULT 1, remind_small_snack INTEGER NOT NULL DEFAULT 0, remind_nuts INTEGER NOT NULL DEFAULT 0, @@ -80,6 +85,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, @@ -96,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')), diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index 227156a..d6473f9 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -260,32 +260,54 @@ h3, @media (min-width: 1081px) { .site-header { - grid-template-columns: 1fr; + grid-template-columns: auto minmax(0, 1fr); + column-gap: 1.5rem; row-gap: 0.9rem; + align-items: center; } .desktop-header-main { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - align-items: center; - gap: 1.4rem; + display: contents; } .desktop-header-sub { - display: flex; - justify-content: flex-start; + display: contents; } .desktop-nav { - width: 100%; + grid-column: 2; + grid-row: 1; + display: flex; + flex-wrap: nowrap; justify-content: flex-start; + align-items: center; + overflow-x: auto; + scrollbar-width: none; + } + + .desktop-nav::-webkit-scrollbar { + display: none; } .desktop-actions { - width: 100%; + grid-column: 2; + grid-row: 2; + display: flex; + flex-wrap: nowrap; justify-content: flex-start; align-self: center; } + + .brand { + grid-column: 1; + grid-row: 1 / span 2; + align-self: center; + } + + .nav-link-inner, + .desktop-actions > * { + white-space: nowrap; + } } .user-chip, diff --git a/nouri/static/pwa/service-worker.js b/nouri/static/pwa/service-worker.js index 3912614..7e1ba66 100644 --- a/nouri/static/pwa/service-worker.js +++ b/nouri/static/pwa/service-worker.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "nouri-v0-6-0"; +const CACHE_NAME = "nouri-v1-0-0"; const OFFLINE_URL = "/static/pwa/offline.html"; const STATIC_ASSETS = [ "/static/css/styles.css", diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html index edc8570..83be4f3 100644 --- a/nouri/templates/items/form.html +++ b/nouri/templates/items/form.html @@ -55,6 +55,16 @@ + + +