5 Commits

52 changed files with 4712 additions and 547 deletions
+3
View File
@@ -9,3 +9,6 @@ __pycache__/
data/
instance/
.cloudron-push.env
.env.local
.env.push.local
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen",
"version": "0.3.0",
"upstreamVersion": "0.3.0",
"version": "0.5.0",
"upstreamVersion": "0.5.0",
"healthCheckPath": "/",
"httpPort": 8000,
"manifestVersion": 2,
+18 -6
View File
@@ -2,19 +2,31 @@ FROM python:3.13-slim
WORKDIR /app/code
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PORT=8000
RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 \
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
RUN 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
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY . /app/code
RUN chmod +x /app/code/start.sh
# Lokale Daten für den ersten Start sichern und danach /app/code/data auf /app/data zeigen lassen
RUN if [ -d /app/code/data ]; then mv /app/code/data /app/bootstrap-data; else mkdir -p /app/bootstrap-data; fi \
&& ln -s /app/data /app/code/data \
&& chmod +x /app/code/start.sh \
&& chown -R cloudron:cloudron /app/code /app/bootstrap-data \
&& chown -h cloudron:cloudron /app/code/data
USER cloudron
EXPOSE 8000
+52
View File
@@ -0,0 +1,52 @@
PolyForm Noncommercial License 1.0.0
Acceptance
In order to get any license under these terms, you must agree to them as
both strict obligations and conditions to all your licenses.
Copyright License
The licensor grants you a copyright license for the software to do everything
you might do with the software that would otherwise infringe the licensor's
copyright in it for any permitted purpose.
Limitations
You may use this software for private, personal, internal, or other
noncommercial purposes only.
Any use of this software or any derivative work for commercial advantage or
monetary compensation is not permitted under this license.
Distribution of modified or unmodified copies must include this license text
and all copyright notices.
Patent License
The licensor grants you a patent license for the software that covers patent
claims the licensor can license, or becomes able to license, that you would
otherwise infringe by using the software for any permitted purpose.
Notices
You must ensure that anyone who gets a copy of any part of this software from
you also gets a copy of these terms.
No Liability
As far as the law allows, the software comes as is, without any warranty or
condition, and the licensor will not be liable to you for any damages arising
out of these terms or the use or nature of the software, under any kind of
legal claim.
Definitions
The licensor is the person or entity offering these terms, and the software is
the software the licensor makes available under these terms, including any
portion of it.
Noncommercial means not primarily intended for or directed toward commercial
advantage or monetary compensation.
If you need a standardized reference text, see https://polyformproject.org/licenses/noncommercial/1.0.0/
+41
View File
@@ -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.
+21 -3
View File
@@ -2,7 +2,7 @@
Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
## Merkmale in Version 0.3
## Merkmale in Version 0.5
- Lebensmittel und Mahlzeitenideen anlegen
- Fotos lokal hochladen
@@ -17,6 +17,17 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
- Profilseite und Passwortänderung
- kleine Admin-Verwaltung für Nutzer
- kompaktere mobile Navigation mit Bottom-Bar
- Tagesvorlagen und Wochenvorlagen
- kleine Pakete für wiederkehrende Einkaufs- oder Planungsbausteine
- ruhige Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen
- globale Kategorien pro Haushalt
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten
- Mobile-Mehr-Menü als Sheet statt eigener Seite
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
- ausgewogene Ergänzungsvorschläge auf Basis ruhiger Bausteine
- einfache Kombinations- und Rezeptideen aus zuhause vorhandenen Lebensmitteln
- Optionen für Erinnerungen, Hinweise und kleine Routinen
- PWA-Vorbereitung mit Web App Manifest, Service Worker und optionalem Web Push
## Lokal starten
@@ -38,10 +49,13 @@ Wichtige Umgebungsvariablen:
- `NOURI_SECRET_KEY`: Session-Secret für Produktion
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
- `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push
- `NOURI_VAPID_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push
- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io`
## Migration von 0.2 auf 0.3
## Migration von 0.4 auf 0.5
Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite: Haushalt, Rollen, Aktiv-Status und Sichtbarkeit (`persönlich` oder `Für alle`) werden ergänzt. Vorhandene 0.2-Daten bleiben erhalten und werden automatisch einem gemeinsamen Haushaltskontext zugeordnet.
Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Einkaufsrhythmus, vorgemerkte Bedarfe, Nutzer-Einstellungen, Push-Registrierungen und Baustein-Zuordnungen für Kategorien werden ergänzt. Vorhandene 0.4-Daten bleiben erhalten und werden weiterverwendet.
## Cloudron-Hinweis
@@ -55,3 +69,7 @@ Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt:
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten
Wenn die App auf Cloudron bereits installiert ist, bitte **kein neues `cloudron install`** ausführen. Stattdessen die bestehende App aktualisieren, also ein neues Image bzw. Paket bauen und dann die vorhandene Installation updaten.
## Lizenz
Nouri ist in diesem Repository für private, nicht-kommerzielle Nutzung freigegeben. Details stehen in [LICENSE.md](LICENSE.md).
+47
View File
@@ -0,0 +1,47 @@
# Nouri 0.5.0
## Highlights
- Einkaufsrhythmus mit geplantem Einkaufstag und ruhiger Vorlauf-Logik
- Optionen für Erinnerungen, Hinweise und kleine Routinen
- Ausgewogene Ergänzungsvorschläge ohne Diät- oder Kontrollsprache
- Einfache Rezeptideen aus zuhause vorhandenen Lebensmitteln
- PWA-Vorbereitung mit Home-Screen-Nutzung, Service Worker und optionalem Web Push
- Überarbeitetes mobiles Mehr-Menü als Karten-Sheet mit Icons
## Neu in 0.5.0
### Planung und Vorschläge
- Der Tagesplan priorisiert jetzt vorhandene Mahlzeitenideen, dann passende Kombinationsvorschläge und danach einzelne Lebensmittel.
- Für Mittag- und Abendessen zeigt Nouri kleine Ergänzungsideen, wenn noch etwas gut dazupassen könnte.
- Zuhause vorhandene Lebensmittel werden zu einfachen Frühstücks- und Hauptmahlzeit-Ideen kombiniert.
### Einkauf
- Fehlende Lebensmittel aus zukünftigen Planungen landen nicht mehr sofort auf der Einkaufsliste.
- Stattdessen merkt Nouri sie zunächst für spätere Einkäufe vor und aktiviert sie passend zum eingestellten Einkaufstag.
- Auch bei Mahlzeiten werden nur die tatsächlich fehlenden Lebensmittel auf den Einkauf bezogen, nicht die Mahlzeit selbst.
### Einstellungen
- Neuer Bereich `Optionen` für Einkaufstag, Vorlauf, Hinweise, Routinen und Push.
- Hinweise lassen sich pro Person fein, aber weiterhin niedrigschwellig steuern.
- Push kann optional je Gerät aktiviert oder wieder beendet werden.
### Mobile und PWA
- Web App Manifest und Service Worker sind ergänzt.
- Nouri lässt sich besser auf dem iPhone zum Home-Bildschirm hinzufügen.
- Das mobile Mehr-Menü öffnet sich als kompaktes Karten-Sheet direkt über der Bottom Navigation.
### Haushalt und Kategorien
- Kategorien können jetzt zusätzlich einem ruhigen Baustein zugeordnet werden, zum Beispiel Proteinquelle, Gemüse oder Obst.
- Diese Zuordnung hilft nur intern bei Vorschlägen und fühlt sich nicht wie Tracking an.
## Technische Hinweise
- Neue SQLite-Tabellen und Spalten werden beim Start automatisch ergänzt.
- Für Web Push werden VAPID-Schlüssel über Umgebungsvariablen unterstützt.
- Cloudron-Version wurde auf `0.5.0` angehoben.
+27 -2
View File
@@ -11,13 +11,18 @@ from . import db
from .admin import admin_bp
from .auth import auth_bp
from .constants import (
CATEGORIES,
BUILDER_DESCRIPTIONS,
BUILDER_LABELS,
BUILDER_OPTIONS,
DAYPARTS,
DEFAULT_CATEGORIES,
ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS,
NOTIFICATION_CHANNEL_OPTIONS,
ROLE_LABELS,
VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS,
WEEKDAY_OPTIONS,
)
from .main import main_bp
@@ -63,6 +68,10 @@ def create_app() -> Flask:
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
APP_VERSION="0.5.0",
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
)
db.init_app(app)
@@ -77,12 +86,20 @@ def create_app() -> Flask:
return {
"item_kind_labels": ITEM_KIND_LABELS,
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
"category_suggestions": CATEGORIES,
"category_suggestions": DEFAULT_CATEGORIES,
"builder_labels": BUILDER_LABELS,
"builder_descriptions": BUILDER_DESCRIPTIONS,
"builder_options": BUILDER_OPTIONS,
"daypart_suggestions": DAYPARTS,
"visibility_labels": VISIBILITY_LABELS,
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
"role_labels": ROLE_LABELS,
"weekday_options": WEEKDAY_OPTIONS,
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
"today": date.today(),
"app_version": app.config["APP_VERSION"],
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
@@ -92,4 +109,12 @@ def create_app() -> Flask:
def uploaded_file(filename: str):
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
@app.get("/app.webmanifest")
def webmanifest():
return send_from_directory(root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json")
@app.get("/service-worker.js")
def service_worker():
return send_from_directory(root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript")
return app
+112 -1
View File
@@ -4,7 +4,7 @@ from flask import Blueprint, flash, g, redirect, render_template, request, url_f
from werkzeug.security import generate_password_hash
from .auth import admin_required, can_remove_last_admin, validate_admin_user_form
from .constants import ROLE_LABELS
from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS
from .db import get_db
@@ -26,6 +26,18 @@ def get_household_user(user_id: int):
return user
def fetch_household_categories():
return get_db().execute(
"""
SELECT *
FROM household_categories
WHERE household_id = ?
ORDER BY is_active DESC, sort_order, LOWER(name)
""",
(g.user["household_id"],),
).fetchall()
@admin_bp.get("/users")
@admin_required
def user_list():
@@ -178,3 +190,102 @@ def user_edit(user_id: int):
flash(error, "error")
return render_template("admin/user_form.html", user=user, form_data=form_data, role_labels=ROLE_LABELS)
@admin_bp.route("/categories", methods=("GET", "POST"))
@admin_required
def category_settings():
if request.method == "POST":
name = request.form.get("name", "").strip()
builder_key = request.form.get("builder_key", "neutral").strip()
if not name:
flash("Bitte einen Kategorienamen eintragen.", "error")
else:
existing = get_db().execute(
"""
SELECT id
FROM household_categories
WHERE household_id = ? AND LOWER(name) = LOWER(?)
""",
(g.user["household_id"], name),
).fetchone()
if existing:
get_db().execute(
"UPDATE household_categories SET is_active = 1, builder_key = ? WHERE id = ?",
(builder_key, existing["id"]),
)
flash("Die Kategorie ist wieder aktiv.", "success")
else:
sort_row = get_db().execute(
"SELECT COALESCE(MAX(sort_order), 0) AS max_sort FROM household_categories WHERE household_id = ?",
(g.user["household_id"],),
).fetchone()
get_db().execute(
"""
INSERT INTO household_categories (household_id, name, builder_key, sort_order, is_active)
VALUES (?, ?, ?, ?, 1)
""",
(g.user["household_id"], name, builder_key, int(sort_row["max_sort"]) + 10),
)
flash("Die Kategorie wurde ergänzt.", "success")
get_db().commit()
return redirect(url_for("admin.category_settings"))
return render_template(
"admin/categories.html",
categories=fetch_household_categories(),
default_categories=DEFAULT_CATEGORIES,
default_category_builders=DEFAULT_CATEGORY_BUILDERS,
builder_options=BUILDER_OPTIONS,
builder_descriptions=BUILDER_DESCRIPTIONS,
)
@admin_bp.post("/categories/<int:category_id>/toggle")
@admin_required
def category_toggle(category_id: int):
category = get_db().execute(
"""
SELECT *
FROM household_categories
WHERE id = ? AND household_id = ?
""",
(category_id, g.user["household_id"]),
).fetchone()
if category is None:
flash("Die Kategorie wurde nicht gefunden.", "error")
return redirect(url_for("admin.category_settings"))
new_state = 0 if category["is_active"] else 1
get_db().execute(
"UPDATE household_categories SET is_active = ? WHERE id = ?",
(new_state, category_id),
)
get_db().commit()
flash("Die Kategorie wurde aktualisiert.", "success")
return redirect(url_for("admin.category_settings"))
@admin_bp.post("/categories/<int:category_id>/update")
@admin_required
def category_update(category_id: int):
category = get_db().execute(
"""
SELECT *
FROM household_categories
WHERE id = ? AND household_id = ?
""",
(category_id, g.user["household_id"]),
).fetchone()
if category is None:
flash("Die Kategorie wurde nicht gefunden.", "error")
return redirect(url_for("admin.category_settings"))
builder_key = request.form.get("builder_key", "neutral").strip()
get_db().execute(
"UPDATE household_categories SET builder_key = ? WHERE id = ?",
(builder_key, category_id),
)
get_db().commit()
flash("Die Zuordnung wurde aktualisiert.", "success")
return redirect(url_for("admin.category_settings"))
+76 -2
View File
@@ -7,7 +7,7 @@ DAYPARTS = [
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
]
CATEGORIES = [
DEFAULT_CATEGORIES = [
"Brot & Getreide",
"Milchprodukt",
"Obst",
@@ -20,6 +20,57 @@ CATEGORIES = [
"Kleines Essen",
]
DEFAULT_CATEGORY_BUILDERS = {
"Brot & Getreide": "carb",
"Milchprodukt": "dairy",
"Obst": "fruit",
"Gemüse": "veg",
"Eiweißquelle": "protein",
"Snack": "neutral",
"Getränk": "neutral",
"Vorrat & Basics": "neutral",
"Warmes": "carb",
"Kleines Essen": "neutral",
}
BUILDER_LABELS = {
"protein": "Proteinquelle",
"carb": "Kohlenhydratquelle",
"veg": "Gemüse / Ballaststoffquelle",
"nuts": "Nüsse / Samen",
"fruit": "Obst",
"dairy": "Milchprodukt",
"neutral": "Neutral / sonstiges",
}
BUILDER_DESCRIPTIONS = {
"protein": "Passt eher zu sättigenden Eiweißquellen.",
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
"veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.",
"nuts": "Passt eher zu Nüssen oder Samen.",
"fruit": "Passt eher zu Obst.",
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
"neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.",
}
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
WEEKDAY_OPTIONS = [
(0, "Montag"),
(1, "Dienstag"),
(2, "Mittwoch"),
(3, "Donnerstag"),
(4, "Freitag"),
(5, "Samstag"),
(6, "Sonntag"),
]
NOTIFICATION_CHANNEL_OPTIONS = [
("in_app", "Nur in der App"),
("push", "Nur Push"),
("both", "App und Push"),
]
ITEM_KIND_LABELS = {
"food": "Lebensmittel",
"meal": "Mahlzeitenideen",
@@ -42,7 +93,7 @@ ROLE_LABELS = {
}
VISIBILITY_LABELS = {
"shared": "Für alle",
"shared": "Gemeinsam",
"personal": "Persönlich",
}
@@ -50,3 +101,26 @@ VISIBILITY_DESCRIPTIONS = {
"shared": "Gemeinsam im Haushalt sichtbar und nutzbar.",
"personal": "Nur für dich sichtbar und planbar.",
}
DAY_TEMPLATE_NAME_SUGGESTIONS = [
"Ruhiger Tag",
"Einfacher Bürotag",
"Schwieriger Tag",
"Standard-Frühstückstag",
"Tag mit wenig Energie",
]
WEEK_TEMPLATE_NAME_SUGGESTIONS = [
"Standardwoche",
"Büro-Woche",
"Leichte Woche",
"Woche mit wenig Energie",
"Frühstücks-Woche",
]
ITEM_SET_NAME_SUGGESTIONS = [
"Schnelles Frühstück",
"Sicherer Snack",
"Einfaches Abendessen",
"Einkauf für zwei Tage",
]
+178 -39
View File
@@ -8,7 +8,7 @@ from flask import Flask, current_app, g
from flask.cli import with_appcontext
from werkzeug.security import generate_password_hash
from .constants import DAYPARTS
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
def get_db() -> sqlite3.Connection:
@@ -33,6 +33,14 @@ def table_columns(database: sqlite3.Connection, table_name: str) -> set[str]:
return {row["name"] for row in rows}
def table_exists(database: sqlite3.Connection, table_name: str) -> bool:
row = database.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table_name,),
).fetchone()
return row is not None
def add_column_if_missing(database: sqlite3.Connection, table_name: str, definition: str) -> None:
column_name = definition.split()[0]
if column_name not in table_columns(database, table_name):
@@ -45,36 +53,134 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
shopping_weekday INTEGER NOT NULL DEFAULT 5,
shopping_prep_days INTEGER NOT NULL DEFAULT 1,
shopping_reminder_time TEXT NOT NULL DEFAULT '18:00',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
existing_tables = {
row["name"]
for row in database.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
database.execute(
"""
CREATE TABLE IF NOT EXISTS household_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
name TEXT NOT NULL,
builder_key TEXT NOT NULL DEFAULT 'neutral',
sort_order INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (household_id, name)
)
"""
)
if "users" in existing_tables:
if table_exists(database, "households"):
add_column_if_missing(database, "households", "shopping_weekday INTEGER NOT NULL DEFAULT 5")
add_column_if_missing(database, "households", "shopping_prep_days INTEGER NOT NULL DEFAULT 1")
add_column_if_missing(database, "households", "shopping_reminder_time TEXT NOT NULL DEFAULT '18:00'")
if table_exists(database, "household_categories"):
add_column_if_missing(database, "household_categories", "builder_key TEXT NOT NULL DEFAULT 'neutral'")
if table_exists(database, "users"):
add_column_if_missing(database, "users", "household_id INTEGER")
add_column_if_missing(database, "users", "email TEXT")
add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'")
add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1")
add_column_if_missing(database, "users", "updated_at TEXT")
if "items" in existing_tables:
if table_exists(database, "items"):
add_column_if_missing(database, "items", "household_id INTEGER")
add_column_if_missing(database, "items", "owner_user_id INTEGER")
add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
if "shopping_entries" in existing_tables:
if table_exists(database, "shopping_entries"):
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER")
add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
if "plan_entries" in existing_tables:
if table_exists(database, "shopping_needs"):
add_column_if_missing(database, "shopping_needs", "source_item_id INTEGER")
add_column_if_missing(database, "shopping_needs", "activation_date TEXT")
add_column_if_missing(database, "shopping_needs", "is_activated INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "shopping_needs", "activated_at TEXT")
database.execute(
"""
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY,
reminders_enabled INTEGER NOT NULL DEFAULT 1,
push_enabled INTEGER NOT NULL DEFAULT 0,
notification_channel TEXT NOT NULL DEFAULT 'in_app',
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
remind_small_snack INTEGER NOT NULL DEFAULT 0,
remind_nuts INTEGER NOT NULL DEFAULT 0,
show_meal_balancing INTEGER NOT NULL DEFAULT 1,
suggest_templates INTEGER NOT NULL DEFAULT 1,
suggest_patterns INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
database.execute(
"""
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_test_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
database.execute(
"""
CREATE TABLE IF NOT EXISTS shopping_needs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
item_id INTEGER NOT NULL,
source_item_id INTEGER,
needed_for_date TEXT NOT NULL,
needed_for_daypart_id INTEGER,
activation_date TEXT NOT NULL,
is_activated INTEGER NOT NULL DEFAULT 0,
activated_at TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility),
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (source_item_id) REFERENCES items(id) ON DELETE SET NULL,
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
if table_exists(database, "plan_entries"):
add_column_if_missing(database, "plan_entries", "household_id INTEGER")
add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER")
add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
@@ -91,9 +197,11 @@ def ensure_default_household(database: sqlite3.Connection) -> int:
"INSERT INTO households (name) VALUES (?)",
("Unser Haushalt",),
)
return int(
database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"]
)
return int(database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"])
def household_ids(database: sqlite3.Connection) -> list[int]:
return [int(row["id"]) for row in database.execute("SELECT id FROM households ORDER BY id").fetchall()]
def first_user_id(database: sqlite3.Connection) -> int | None:
@@ -101,6 +209,26 @@ def first_user_id(database: sqlite3.Connection) -> int | None:
return int(row["id"]) if row else None
def sync_default_categories(database: sqlite3.Connection) -> None:
for household_id in household_ids(database):
for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10):
database.execute(
"""
INSERT OR IGNORE INTO household_categories (household_id, name, builder_key, sort_order, is_active)
VALUES (?, ?, ?, ?, 1)
""",
(household_id, name, DEFAULT_CATEGORY_BUILDERS.get(name, "neutral"), sort_order),
)
database.execute(
"""
UPDATE household_categories
SET builder_key = COALESCE(NULLIF(builder_key, ''), ?)
WHERE household_id = ? AND name = ?
""",
(DEFAULT_CATEGORY_BUILDERS.get(name, "neutral"), household_id, name),
)
def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "users", "household_id INTEGER")
add_column_if_missing(database, "users", "email TEXT")
@@ -109,22 +237,19 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "users", "updated_at TEXT")
default_household_id = ensure_default_household(database)
database.execute("UPDATE households SET shopping_weekday = COALESCE(shopping_weekday, 5)")
database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)")
database.execute(
"UPDATE households SET shopping_reminder_time = COALESCE(NULLIF(shopping_reminder_time, ''), '18:00')"
)
database.execute(
"UPDATE users SET household_id = ? WHERE household_id IS NULL",
(default_household_id,),
)
database.execute(
"UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''",
)
database.execute(
"UPDATE users SET is_active = 1 WHERE is_active IS NULL",
)
database.execute(
"UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''",
)
database.execute(
"UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)"
)
database.execute("UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''")
database.execute("UPDATE users SET is_active = 1 WHERE is_active IS NULL")
database.execute("UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''")
database.execute("UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)")
admin_row = database.execute(
"SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY id LIMIT 1"
@@ -132,16 +257,16 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
if admin_row is None:
first_id = first_user_id(database)
if first_id is not None:
database.execute(
"UPDATE users SET role = 'admin' WHERE id = ?",
(first_id,),
)
database.execute("UPDATE users SET role = 'admin' WHERE id = ?", (first_id,))
default_owner_id = first_user_id(database)
for table_name in ("items", "shopping_entries", "plan_entries"):
add_column_if_missing(database, table_name, "household_id INTEGER")
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
if default_owner_id is not None:
database.execute(
@@ -175,15 +300,17 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
(default_household_id, default_owner_id),
)
else:
database.execute(
"UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''"
)
database.execute(
"UPDATE shopping_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''"
)
database.execute(
"UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''"
)
database.execute("UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
database.execute("UPDATE shopping_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
sync_default_categories(database)
database.execute(
"""
INSERT OR IGNORE INTO user_settings (user_id)
SELECT id FROM users
"""
)
database.execute(
"""
@@ -198,6 +325,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
ON items (household_id, visibility, availability_state)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_items_target_user
ON items (target_user_id)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
@@ -210,6 +343,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
ON shopping_entries (household_id, visibility, is_checked)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
ON shopping_needs (household_id, activation_date, is_activated)
"""
)
def apply_schema(database: sqlite3.Connection) -> None:
+2163 -331
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from typing import Any
from flask import current_app
def push_is_configured() -> bool:
return bool(
current_app.config.get("VAPID_PUBLIC_KEY")
and current_app.config.get("VAPID_PRIVATE_KEY")
and current_app.config.get("VAPID_SUBJECT")
)
def push_public_key() -> str | None:
return current_app.config.get("VAPID_PUBLIC_KEY") or None
def send_push_message(subscription: dict[str, Any], *, title: str, body: str, url: str) -> tuple[bool, str | None]:
if not push_is_configured():
return False, "Push ist noch nicht konfiguriert."
try:
from pywebpush import WebPushException, webpush
except Exception:
return False, "Die Push-Bibliothek ist noch nicht installiert."
payload = json.dumps(
{
"title": title,
"body": body,
"url": url,
"icon": "/static/brand/pwa-192.png",
"badge": "/static/brand/pwa-badge.png",
}
)
try:
webpush(
subscription_info=subscription,
data=payload,
vapid_private_key=current_app.config["VAPID_PRIVATE_KEY"],
vapid_claims={"sub": current_app.config["VAPID_SUBJECT"]},
)
except WebPushException as exc: # pragma: no cover - depends on live push endpoint
return False, str(exc)
return True, None
+167 -1
View File
@@ -3,6 +3,9 @@ PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
shopping_weekday INTEGER NOT NULL DEFAULT 5,
shopping_prep_days INTEGER NOT NULL DEFAULT 1,
shopping_reminder_time TEXT NOT NULL DEFAULT '18:00',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -24,6 +27,53 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
ON users (email)
WHERE email IS NOT NULL AND email != '';
CREATE TABLE IF NOT EXISTS household_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
name TEXT NOT NULL,
builder_key TEXT NOT NULL DEFAULT 'neutral',
sort_order INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (household_id, name),
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY,
reminders_enabled INTEGER NOT NULL DEFAULT 1,
push_enabled INTEGER NOT NULL DEFAULT 0,
notification_channel TEXT NOT NULL DEFAULT 'in_app',
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
remind_small_snack INTEGER NOT NULL DEFAULT 0,
remind_nuts INTEGER NOT NULL DEFAULT 0,
show_meal_balancing INTEGER NOT NULL DEFAULT 1,
suggest_templates INTEGER NOT NULL DEFAULT 1,
suggest_patterns INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_test_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dayparts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
@@ -35,6 +85,7 @@ CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER,
owner_user_id INTEGER,
target_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
name TEXT NOT NULL,
@@ -48,6 +99,7 @@ CREATE TABLE IF NOT EXISTS items (
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
);
@@ -76,6 +128,8 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
item_id INTEGER NOT NULL,
added_by INTEGER,
checked_by INTEGER,
needed_for_date TEXT,
needed_for_daypart_id INTEGER,
is_checked INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
checked_at TEXT,
@@ -83,13 +137,37 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item
ON shopping_entries (item_id)
WHERE is_checked = 0;
CREATE TABLE IF NOT EXISTS shopping_needs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
item_id INTEGER NOT NULL,
source_item_id INTEGER,
needed_for_date TEXT NOT NULL,
needed_for_daypart_id INTEGER,
activation_date TEXT NOT NULL,
is_activated INTEGER NOT NULL DEFAULT 0,
activated_at TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility),
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (source_item_id) REFERENCES items(id) ON DELETE SET NULL,
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS plan_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER,
@@ -108,12 +186,88 @@ CREATE TABLE IF NOT EXISTS plan_entries (
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS day_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS day_template_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_template_id INTEGER NOT NULL,
daypart_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE,
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS week_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS week_template_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_template_id INTEGER NOT NULL,
weekday_index INTEGER NOT NULL,
day_template_id INTEGER NOT NULL,
UNIQUE (week_template_id, weekday_index),
FOREIGN KEY (week_template_id) REFERENCES week_templates(id) ON DELETE CASCADE,
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS item_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS item_set_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_set_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
UNIQUE (item_set_id, item_id),
FOREIGN KEY (item_set_id) REFERENCES item_sets(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_items_kind_name
ON items (kind, name);
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
ON items (household_id, visibility, availability_state);
CREATE INDEX IF NOT EXISTS idx_items_target_user
ON items (target_user_id);
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
ON item_dayparts (daypart_id, item_id);
@@ -125,3 +279,15 @@ ON plan_entries (household_id, visibility, plan_date);
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
ON shopping_entries (household_id, visibility, is_checked);
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
ON shopping_needs (household_id, activation_date, is_activated);
CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility
ON day_templates (household_id, visibility, name);
CREATE INDEX IF NOT EXISTS idx_week_templates_household_visibility
ON week_templates (household_id, visibility, name);
CREATE INDEX IF NOT EXISTS idx_item_sets_household_visibility
ON item_sets (household_id, visibility, name);
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+357 -2
View File
@@ -67,6 +67,10 @@ body.has-mobile-nav {
padding-bottom: 6rem;
}
body.sheet-open {
overflow: hidden;
}
a {
color: inherit;
text-decoration: none;
@@ -124,6 +128,29 @@ button.secondary:hover,
margin: 1rem auto 2rem;
}
.site-footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.75rem;
padding: 1rem 0.35rem 0;
color: var(--muted);
font-size: 0.92rem;
}
.footer-copy {
display: inline-flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
}
.footer-copy .ui-icon {
width: 0.95rem;
height: 0.95rem;
color: var(--accent-strong);
}
.site-header {
position: sticky;
top: 1rem;
@@ -256,6 +283,11 @@ h3,
display: none;
}
.mobile-sheet-backdrop[hidden],
.mobile-more-sheet[hidden] {
display: none;
}
.content {
display: grid;
gap: 1.2rem;
@@ -436,6 +468,7 @@ h3 {
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 1rem 1.1rem;
}
.stacked-mobile {
@@ -576,6 +609,12 @@ h3 {
gap: 1rem;
}
.template-library-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.stack-form label,
.planner-entry-form label,
.filter-form label,
@@ -601,6 +640,7 @@ input[type="text"],
input[type="email"],
input[type="password"],
input[type="date"],
input[type="time"],
input[type="file"],
select,
textarea {
@@ -640,6 +680,37 @@ legend {
border: 1px solid var(--line);
}
.check-option[hidden],
.quick-select-card[hidden] {
display: none;
}
.quick-select-card {
display: flex;
align-items: flex-start;
gap: 0.7rem;
padding: 0.85rem 0.95rem;
min-width: 220px;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 82%, #fff 18%);
}
.quick-select-card strong,
.template-list-card strong {
display: block;
}
.quick-select-card small,
.template-list-card p,
.template-list-card small,
.hint-chip,
.suggestion-card p,
.suggestion-card small,
.week-template-row p {
color: var(--muted);
}
.inline-photo img {
width: min(220px, 100%);
border-radius: 18px;
@@ -739,6 +810,10 @@ legend {
margin-bottom: 1rem;
}
.template-search-row {
margin-bottom: 0.9rem;
}
.quick-add-row form {
margin: 0;
}
@@ -769,6 +844,154 @@ legend {
background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
}
.template-card,
.template-list-card,
.suggestion-card {
padding: 0.95rem 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
}
.template-list-card,
.week-template-row {
display: grid;
gap: 0.9rem;
}
.week-template-row {
padding: 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.34);
}
.hint-list {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.hint-chip {
margin: 0;
padding: 0.85rem 1rem;
border-radius: 16px;
background: color-mix(in srgb, var(--surface-strong) 80%, #fff 20%);
border: 1px solid var(--line);
}
.suggestion-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.planner-subsection {
display: grid;
gap: 0.8rem;
margin-bottom: 1rem;
}
.planner-subsection h3 {
font-size: 1rem;
}
.planner-search {
display: grid;
gap: 0.45rem;
color: var(--muted);
}
.compact-picker-list {
display: grid;
gap: 0.55rem;
}
.compact-picker-list form[hidden] {
display: none;
}
.picker-row {
width: 100%;
justify-content: space-between;
padding: 0.85rem 1rem;
border-radius: 16px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%);
color: var(--text);
}
.picker-row small {
color: var(--muted);
}
.compact-quick-row {
margin-bottom: 0;
}
.compact-button {
min-width: 150px;
padding: 0.78rem 0.9rem;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.pwa-card {
padding: 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
}
.card-link-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.menu-card {
display: grid;
justify-items: start;
gap: 0.5rem;
padding: 1rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%);
}
.menu-card .ui-icon {
width: 1.15rem;
height: 1.15rem;
color: var(--accent-strong);
}
.roomy-row {
padding: 1rem 1.2rem;
}
.inline-form-tight {
grid-template-columns: 1fr auto;
}
.inline-form-tight > :first-child {
grid-column: auto;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.planner-entry-top {
align-items: flex-start;
}
@@ -915,6 +1138,99 @@ legend {
mask-image: url("../icons/fa/sparkles.svg");
}
.icon-layer-group {
-webkit-mask-image: url("../icons/fa/layer-group.svg");
mask-image: url("../icons/fa/layer-group.svg");
}
.icon-ellipsis {
-webkit-mask-image: url("../icons/fa/ellipsis.svg");
mask-image: url("../icons/fa/ellipsis.svg");
}
.icon-heart {
-webkit-mask-image: url("../icons/fa/heart.svg");
mask-image: url("../icons/fa/heart.svg");
}
.icon-sliders {
-webkit-mask-image: url("../icons/fa/sliders.svg");
mask-image: url("../icons/fa/sliders.svg");
}
.icon-seedling {
-webkit-mask-image: url("../icons/fa/seedling.svg");
mask-image: url("../icons/fa/seedling.svg");
}
.icon-bell {
-webkit-mask-image: url("../icons/fa/bell.svg");
mask-image: url("../icons/fa/bell.svg");
}
.icon-mobile-screen-button {
-webkit-mask-image: url("../icons/fa/mobile-screen-button.svg");
mask-image: url("../icons/fa/mobile-screen-button.svg");
}
.icon-apple-whole {
-webkit-mask-image: url("../icons/fa/apple-whole.svg");
mask-image: url("../icons/fa/apple-whole.svg");
}
.icon-leaf {
-webkit-mask-image: url("../icons/fa/leaf.svg");
mask-image: url("../icons/fa/leaf.svg");
}
.mobile-sheet-backdrop {
position: fixed;
inset: 0;
z-index: 24;
background: rgba(33, 29, 28, 0.22);
backdrop-filter: blur(6px);
}
.mobile-more-sheet {
position: fixed;
left: 0.75rem;
right: 0.75rem;
bottom: 5.9rem;
z-index: 25;
padding: 1rem;
border-radius: 24px;
border: 1px solid var(--line);
background: var(--bg-elevated);
box-shadow: var(--shadow);
backdrop-filter: blur(24px) saturate(1.1);
}
.mobile-sheet-head,
.mobile-sheet-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.mobile-sheet-head small {
color: var(--muted);
}
.mobile-sheet-links {
display: grid;
gap: 0.75rem;
margin: 1rem 0;
}
.mobile-sheet-actions {
flex-wrap: wrap;
}
.mobile-sheet-actions > * {
flex: 1 1 180px;
}
@media (max-width: 1080px) {
.site-header,
.hero,
@@ -932,6 +1248,8 @@ legend {
.stats-grid,
.two-column,
.template-library-grid,
.settings-grid,
.inline-form,
.planner-entry-form,
.planner-entry-form-wide,
@@ -999,6 +1317,10 @@ legend {
padding: 1rem;
}
.site-footer {
padding-bottom: 5.6rem;
}
h1 {
font-size: clamp(1.6rem, 7vw, 2rem);
}
@@ -1014,7 +1336,10 @@ legend {
.mini-card-grid,
.week-mini-grid,
.week-overview-grid,
.more-link-grid {
.more-link-grid,
.template-library-grid,
.settings-grid,
.card-link-grid {
grid-template-columns: 1fr;
}
@@ -1079,7 +1404,20 @@ legend {
font-size: 0.78rem;
}
.mobile-bottom-nav a.active {
.mobile-nav-button {
display: grid;
justify-items: center;
gap: 0.28rem;
padding: 0.55rem 0.35rem;
border-radius: 16px;
border: 0;
background: transparent;
color: var(--muted);
font-size: 0.78rem;
}
.mobile-bottom-nav a.active,
.mobile-nav-button.is-open {
background: var(--accent-soft);
color: var(--text);
}
@@ -1088,4 +1426,21 @@ legend {
width: 1rem;
height: 1rem;
}
.mobile-profile-link {
display: inline-flex;
padding: 0.35rem;
background: transparent;
}
.mobile-profile-link .mobile-profile-avatar {
width: 2.15rem;
height: 2.15rem;
}
.mobile-sheet-head,
.mobile-sheet-actions,
.week-template-row {
align-items: flex-start;
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 112c-8.8 0-16-7.2-16-16l0-16c0-44.2 35.8-80 80-80l16 0c8.8 0 16 7.2 16 16l0 16c0 44.2-35.8 80-80 80l-16 0zM0 288c0-76.3 35.7-160 112-160 27.3 0 59.7 10.3 82.7 19.3 18.8 7.3 39.9 7.3 58.7 0 22.9-8.9 55.4-19.3 82.7-19.3 76.3 0 112 83.7 112 160 0 128-80 224-160 224-16.5 0-38.1-6.6-51.5-11.3-8.1-2.8-16.9-2.8-25 0-13.4 4.7-35 11.3-51.5 11.3-80 0-160-96-160-224z"/></svg>

After

Width:  |  Height:  |  Size: 631 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32l0 3.2C119 50 64 114.6 64 192l0 21.7c0 48.1-16.4 94.8-46.4 132.4L7.8 358.3C2.7 364.6 0 372.4 0 380.5 0 400.1 15.9 416 35.5 416l376.9 0c19.6 0 35.5-15.9 35.5-35.5 0-8.1-2.7-15.9-7.8-22.2l-9.8-12.2C400.4 308.5 384 261.8 384 213.7l0-21.7c0-77.4-55-142-128-156.8l0-3.2c0-17.7-14.3-32-32-32zM162 464c7.1 27.6 32.2 48 62 48s54.9-20.4 62-48l-124 0z"/></svg>

After

Width:  |  Height:  |  Size: 637 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="19" cy="12" r="2"/>
</svg>

After

Width:  |  Height:  |  Size: 189 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M241 87.1l15 20.7 15-20.7C296 52.5 336.2 32 378.9 32 452.4 32 512 91.6 512 165.1l0 2.6c0 112.2-139.9 242.5-212.9 298.2-12.4 9.4-27.6 14.1-43.1 14.1s-30.8-4.6-43.1-14.1C139.9 410.2 0 279.9 0 167.7l0-2.6C0 91.6 59.6 32 133.1 32 175.8 32 216 52.5 241 87.1z"/></svg>

After

Width:  |  Height:  |  Size: 521 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.6 2 8l10 5.4L22 8 12 2.6Zm-7.9 8.6L2 12.4l10 5.4 10-5.4-2.1-1.2L12 15.4 4.1 11.2Zm0 4.4L2 16.8l10 5.4 10-5.4-2.1-1.2L12 19.8 4.1 15.6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M471.3 6.7C477.7 .6 487-1.6 495.6 1.2 505.4 4.5 512 13.7 512 24l0 186.9c0 131.2-108.1 237.1-238.8 237.1-77 0-143.4-49.5-167.5-118.7-35.4 30.8-57.7 76.1-57.7 126.7 0 13.3-10.7 24-24 24S0 469.3 0 456C0 381.1 38.2 315.1 96.1 276.3 131.4 252.7 173.5 240 216 240l80 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-80 0c-39.7 0-77.3 8.8-111 24.5 23.3-70 89.2-120.5 167-120.5 66.4 0 115.8-22.1 148.7-44 19.2-12.8 35.5-28.1 50.7-45.3z"/></svg>

After

Width:  |  Height:  |  Size: 685 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zm64 0l0 304 224 0 0-304-224 0zM192 472c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32z"/></svg>

After

Width:  |  Height:  |  Size: 487 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M512 32C512 140.1 435.4 230.3 333.6 251.4 325.7 193.3 299.6 141 261.1 100.5 301.2 40 369.9 0 448 0l32 0c17.7 0 32 14.3 32 32zM0 96C0 78.3 14.3 64 32 64l32 0c123.7 0 224 100.3 224 224l0 192c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-160C100.3 320 0 219.7 0 96z"/></svg>

After

Width:  |  Height:  |  Size: 522 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32L265.3 64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48l54.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 224zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-246.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384z"/></svg>

After

Width:  |  Height:  |  Size: 850 B

+96
View File
@@ -0,0 +1,96 @@
(() => {
const getCsrfToken = () => {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute("content") : "";
};
const getPushPublicKey = () => {
const meta = document.querySelector('meta[name="nouri-push-public-key"]');
return meta ? meta.getAttribute("content") : "";
};
const urlBase64ToUint8Array = (base64String) => {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((character) => character.charCodeAt(0)));
};
const registerServiceWorker = async () => {
if (!("serviceWorker" in navigator)) return null;
return navigator.serviceWorker.register("/service-worker.js");
};
const subscribeToPush = async () => {
const publicKey = getPushPublicKey();
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
const registration = await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const existing = await registration.pushManager.getSubscription();
const subscription = existing || await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
const subscriptionJson = subscription.toJSON();
const payload = new URLSearchParams({
csrf_token: getCsrfToken(),
endpoint: subscription.endpoint,
p256dh: subscriptionJson.keys && subscriptionJson.keys.p256dh ? subscriptionJson.keys.p256dh : "",
auth: subscriptionJson.keys && subscriptionJson.keys.auth ? subscriptionJson.keys.auth : "",
});
await fetch("/push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
},
body: payload.toString(),
});
window.location.reload();
};
const unsubscribeFromPush = async () => {
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
const payload = new URLSearchParams({ csrf_token: getCsrfToken() });
if (subscription) {
payload.set("endpoint", subscription.endpoint);
await subscription.unsubscribe();
}
await fetch("/push/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
},
body: payload.toString(),
});
window.location.reload();
};
document.addEventListener("DOMContentLoaded", () => {
registerServiceWorker();
const enableButton = document.querySelector("[data-push-enable]");
const disableButton = document.querySelector("[data-push-disable]");
if (enableButton) {
enableButton.addEventListener("click", () => {
subscribeToPush().catch(() => {
window.location.reload();
});
});
}
if (disableButton) {
disableButton.addEventListener("click", () => {
unsubscribeFromPush().catch(() => {
window.location.reload();
});
});
}
});
})();
+67
View File
@@ -0,0 +1,67 @@
(() => {
const initMobileSheet = () => {
const sheet = document.querySelector("[data-mobile-sheet]");
const backdrop = document.querySelector("[data-mobile-sheet-backdrop]");
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
const closeButtons = document.querySelectorAll("[data-mobile-sheet-close]");
if (!sheet || !backdrop || !openButtons.length) return;
const closeSheet = () => {
sheet.hidden = true;
backdrop.hidden = true;
document.body.classList.remove("sheet-open");
openButtons.forEach((button) => button.classList.remove("is-open"));
};
const openSheet = () => {
sheet.hidden = false;
backdrop.hidden = false;
document.body.classList.add("sheet-open");
openButtons.forEach((button) => button.classList.add("is-open"));
};
openButtons.forEach((button) => {
button.addEventListener("click", openSheet);
});
closeButtons.forEach((button) => {
button.addEventListener("click", closeSheet);
});
backdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeSheet();
}
});
sheet.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", closeSheet);
});
};
const initFilterInputs = () => {
document.querySelectorAll("[data-filter-input]").forEach((input) => {
const listSelector = input.getAttribute("data-filter-target");
if (!listSelector) return;
const container = document.querySelector(listSelector);
if (!container) return;
const items = Array.from(container.querySelectorAll("[data-filter-label]"));
const applyFilter = () => {
const term = input.value.trim().toLowerCase();
items.forEach((item) => {
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
item.hidden = Boolean(term) && !haystack.includes(term);
});
};
input.addEventListener("input", applyFilter);
applyFilter();
});
};
document.addEventListener("DOMContentLoaded", () => {
initMobileSheet();
initFilterInputs();
});
})();
+23
View File
@@ -0,0 +1,23 @@
{
"name": "Nouri",
"short_name": "Nouri",
"description": "einfach essen planen",
"lang": "de",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#fff6ef",
"theme_color": "#efab72",
"icons": [
{
"src": "/static/brand/pwa-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/brand/pwa-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+96
View File
@@ -0,0 +1,96 @@
const CACHE_NAME = "nouri-v0-5-1";
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-192.png",
"/static/brand/pwa-512.png",
"/static/brand/favicon.svg",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
).then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const requestUrl = new URL(event.request.url);
const isStaticAsset =
requestUrl.origin === self.location.origin &&
(
requestUrl.pathname.startsWith("/static/")
|| requestUrl.pathname === "/app.webmanifest"
|| requestUrl.pathname === "/service-worker.js"
);
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
return;
}
if (!isStaticAsset) {
return;
}
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
return cached;
}
return fetch(event.request).then((response) => {
if (!response || response.status !== 200 || response.type !== "basic") {
return response;
}
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || "Nouri", {
body: data.body || "",
icon: data.icon || "/static/brand/pwa-192.png",
badge: data.badge || "/static/brand/pwa-badge.png",
data: { url: data.url || "/" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(targetUrl) && "focus" in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
return null;
})
);
});
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}Kategorien | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Kategorien</p>
<h1>Kategorien global anpassen</h1>
<p class="lead">Hier pflegt ihr die Auswahl für Lebensmittel und Mahlzeiten. Bestehende Einträge bleiben auch dann erhalten, wenn eine Kategorie später pausiert wird.</p>
</div>
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zur Nutzerverwaltung</a>
</section>
<section class="panel compact-form-panel">
<form method="post" class="inline-form">
{{ csrf_input() }}
<label class="wide">
Neue Kategorie
<input type="text" name="name" placeholder="z. B. Süßes, Vorrat, Unterwegs">
</label>
<label>
Passt eher zu
<select name="builder_key">
{% for value, label in builder_options %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Kategorie ergänzen</button>
</form>
</section>
<section class="stack-list">
{% for category in categories %}
<article class="list-row stacked-mobile">
<div>
<strong>{{ category.name }}</strong>
<p class="muted">
{% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %}
· {{ builder_descriptions[category.builder_key] }}
</p>
<div class="chip-row">
<span class="chip">{{ builder_descriptions[category.builder_key].split('.')[0] }}</span>
{% if category.is_active %}
<span class="chip status-home">Aktiv</span>
{% else %}
<span class="chip status-archived">Pausiert</span>
{% endif %}
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('admin.category_update', category_id=category.id) }}" class="inline-form inline-form-tight">
{{ csrf_input() }}
<label>
<span class="sr-only">Baustein</span>
<select name="builder_key">
{% for value, label in builder_options %}
<option value="{{ value }}" {% if category.builder_key == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button class="ghost-button" type="submit">Zuordnung speichern</button>
</form>
<form method="post" action="{{ url_for('admin.category_toggle', category_id=category.id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
</button>
</form>
</div>
</article>
{% endfor %}
</section>
{% endblock %}
+5 -2
View File
@@ -5,9 +5,12 @@
<div>
<p class="eyebrow">Nutzer verwalten</p>
<h1>Haushaltszugänge ruhig pflegen</h1>
<p class="lead">Admins können hier weitere Mitglieder anlegen, Rollen anpassen und Zugänge bei Bedarf pausieren.</p>
<p class="lead">Admins können hier weitere Mitglieder anlegen, Rollen anpassen, Zugänge pausieren und die gemeinsamen Kategorien pflegen.</p>
</div>
<div class="hero-actions">
<a class="button" href="{{ url_for('admin.user_create') }}">Neuen Nutzer anlegen</a>
<a class="button secondary" href="{{ url_for('admin.category_settings') }}">Kategorien</a>
</div>
<a class="button" href="{{ url_for('admin.user_create') }}">Neuen Nutzer anlegen</a>
</section>
<section class="stack-list">
+1
View File
@@ -54,6 +54,7 @@
<div class="chip-row">
<span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span>
<span class="chip">{{ item.for_label }}</span>
</div>
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
{% if item.dayparts %}
+7
View File
@@ -15,6 +15,13 @@
</div>
</section>
<section class="panel compact-form-panel">
<div class="panel-head">
<h2>Optionen</h2>
<a href="{{ url_for('main.settings_view') }}">Zu den Einstellungen</a>
</div>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
+64 -12
View File
@@ -4,11 +4,20 @@
<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="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="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="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>
</head>
<body class="{% if g.user %}has-mobile-nav{% endif %}">
<div class="page-shell">
@@ -27,22 +36,24 @@
<nav class="site-nav desktop-nav">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a>
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
<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>
<div class="header-actions desktop-actions">
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</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>
<small>{{ role_labels[g.user.role] }}</small>
</a>
{% if g.user.role == 'admin' %}
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer verwalten</a>
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer</a>
{% endif %}
<form method="post" action="{{ url_for('auth.logout') }}">
{{ csrf_input() }}
@@ -50,9 +61,9 @@
</form>
</div>
<a class="mobile-profile-link" href="{{ url_for('main.more_view') }}" aria-label="Mehr">
<span class="mobile-profile-avatar">{{ (g.user.display_name or g.user.username)[0]|upper }}</span>
</a>
<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>
</button>
{% endif %}
</header>
@@ -69,9 +80,50 @@
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<div class="footer-copy">
<span>Version {{ app_version }}</span>
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
</div>
<div class="footer-copy">
<span>&copy; 2026 <a href="https://hnz.io" target="_blank" rel="noreferrer">@ hnz.io</a></span>
</div>
</footer>
</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>
{% 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>
{% 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') }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button>
</form>
</div>
</aside>
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
<span class="ui-icon icon-sparkles"></span>
@@ -85,14 +137,14 @@
<span class="ui-icon icon-calendar"></span>
<span>Plan</span>
</a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}">
<span class="ui-icon icon-house"></span>
<span>Zuhause</span>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
<span class="ui-icon icon-calendar-days"></span>
<span>Woche</span>
</a>
<a href="{{ url_for('main.more_view') }}" class="{{ 'active' if request.endpoint == 'main.more_view' or request.endpoint == 'auth.profile' or (request.endpoint or '').startswith('admin.') else '' }}">
<span class="ui-icon icon-archive"></span>
<button type="button" class="mobile-nav-button" data-mobile-sheet-open>
<span class="ui-icon icon-ellipsis"></span>
<span>Mehr</span>
</a>
</button>
</nav>
{% endif %}
</body>
+108 -22
View File
@@ -5,11 +5,11 @@
<div>
<p class="eyebrow">Heute</p>
<h1>Ein ruhiger Blick auf euren Alltag</h1>
<p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde und was gemeinsam oder persönlich vorbereitet ist.</p>
<p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo heute noch etwas ergänzt werden könnte.</p>
</div>
<div class="hero-actions">
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
<a class="button secondary" href="{{ url_for('main.item_create', kind='meal') }}">Mahlzeitenidee anlegen</a>
<a class="button secondary" href="{{ url_for('main.template_library') }}">Vorlagen öffnen</a>
</div>
</section>
@@ -31,6 +31,19 @@
</article>
</section>
{% if dashboard_hints %}
<section class="panel">
<div class="panel-head">
<h2>Heute passend</h2>
</div>
<div class="hint-list">
{% for hint in dashboard_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</section>
{% endif %}
<section class="two-column">
<article class="panel">
<div class="panel-head">
@@ -47,6 +60,7 @@
<div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
</div>
</div>
{% if entry.availability_state == 'home' %}
@@ -72,7 +86,7 @@
<div class="mini-card-body">
<strong>{{ item.name }}</strong>
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}</small>
<small>{{ item.owner_label }}</small>
<small>{{ item.for_label }}</small>
{% if item.dayparts %}
<div class="chip-row">
{% for daypart in item.dayparts %}
@@ -90,24 +104,96 @@
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>Nächste Tage</h2>
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
</div>
<div class="week-mini-grid">
{% for card in week_cards %}
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong>
{% if card.filled_dayparts %}
<span>{{ card.planned_count }} Einträge</span>
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
{% else %}
<span>Noch frei</span>
<small>sanfter Einstieg für den Tag</small>
{% endif %}
</a>
{% endfor %}
</div>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Was zuhause gut zusammenpasst</h2>
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
</div>
{% if recipe_suggestions %}
<div class="stack-sections">
{% for suggestion in recipe_suggestions %}
<article class="template-list-card">
<div>
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
</div>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Sobald ein paar Dinge unter Zuhause liegen, zeigt Nouri hier kleine Kombinationsideen.</p>
{% endif %}
</article>
<article class="panel">
<div class="panel-head">
<h2>Vorlagen für später</h2>
<a href="{{ url_for('main.template_library') }}">Alles ansehen</a>
</div>
{% if day_templates or week_templates %}
<div class="stack-sections">
{% for template in day_templates %}
<a class="mini-card" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">
<strong>{{ template.name }}</strong>
<small>Tagesvorlage · {{ template.visibility_label }}</small>
</a>
{% endfor %}
{% for template in week_templates %}
<a class="mini-card" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">
<strong>{{ template.name }}</strong>
<small>Wochenvorlage · {{ template.visibility_label }}</small>
</a>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Vorlagen helfen später beim Wiederverwenden. Du kannst sie direkt aus einem Tag oder einer Woche heraus anlegen.</p>
{% endif %}
</article>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Nächster Einkauf</h2>
<a href="{{ url_for('main.shopping_list') }}">Zur Einkaufsliste</a>
</div>
{% if upcoming_entries %}
<div class="stack-sections">
{% for entry in upcoming_entries %}
<article class="template-list-card">
<div>
<strong>{{ entry.item_name }}</strong>
<small>Wird ab {{ entry.activation_label }} sichtbar · {{ entry.needed_for_label }}</small>
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Gerade ist nichts für spätere Einkäufe vorgemerkt.</p>
{% endif %}
</article>
<article class="panel">
<div class="panel-head">
<h2>Nächste Tage</h2>
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
</div>
<div class="week-mini-grid">
{% for card in week_cards %}
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong>
{% if card.filled_dayparts %}
<span>{{ card.planned_count }} Einträge</span>
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
{% else %}
<span>Noch frei</span>
<small>ruhiger Einstieg für den Tag</small>
{% endif %}
</a>
{% endfor %}
</div>
</article>
</section>
{% endblock %}
+21
View File
@@ -39,6 +39,26 @@
</form>
</section>
{% if recipe_suggestions %}
<section class="panel">
<div class="panel-head">
<h2>Passt gut dazu</h2>
<a href="{{ url_for('main.item_create', kind='meal') }}">Neue Mahlzeit</a>
</div>
<div class="stack-sections">
{% for suggestion in recipe_suggestions %}
<article class="template-list-card">
<div>
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
</div>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article>
{% endfor %}
</div>
</section>
{% endif %}
{% if sections %}
<section class="stack-sections">
{% for section in sections if section["items"] %}
@@ -62,6 +82,7 @@
<div class="chip-row">
<span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span>
<span class="chip">{{ item.for_label }}</span>
</div>
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
{% if item.components %}
+41 -21
View File
@@ -5,12 +5,13 @@
<div>
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
<p class="lead">Nur das Nötigste: Name, Sichtbarkeit, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.</p>
<p class="lead">Nur das Nötigste: Name, Sichtbarkeit, für wen etwas gedacht ist, Bild, Tageszeiten und eine kleine Notiz.</p>
</div>
{% if item %}
<div class="intro-pills">
<span class="status-pill">{{ item.visibility_label }}</span>
<span class="status-pill status-soft">{{ item.owner_label }}</span>
<span class="status-pill">{{ item.for_label }}</span>
</div>
{% endif %}
</section>
@@ -23,15 +24,26 @@
<input type="text" name="name" value="{{ form_data.name }}" required>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="dual-grid">
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<label>
Für wen?
<select name="target_user_id">
{% for option in target_user_options %}
<option value="{{ option.value }}" {% if form_data.target_user_raw == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
</div>
<label>
Kategorie
@@ -40,9 +52,6 @@
{% for category in categories %}
<option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option>
{% endfor %}
{% if form_data.category and form_data.category not in categories %}
<option value="{{ form_data.category }}" selected>{{ form_data.category }}</option>
{% endif %}
</select>
</label>
@@ -77,20 +86,34 @@
{% if kind == 'meal' %}
<fieldset>
<legend>Bestandteile der Mahlzeitenidee</legend>
<p class="muted">Optional: Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenklicken.</p>
<p class="muted">Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.</p>
<div class="inline-form">
<label class="wide">
Lebensmittel suchen
<input
type="text"
name="food_search"
value="{{ form_data.food_search }}"
placeholder="z. B. Reis, Banane, Joghurt"
data-filter-input
data-filter-target="#meal-components-list"
>
</label>
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
</div>
{% if food_groups %}
<div class="stack-sections">
<div class="stack-sections" id="meal-components-list">
{% for group in food_groups %}
<div class="component-group">
<div class="panel-head">
<h3>{{ group["title"] }}</h3>
<span>{{ group["items"]|length }} Einträge</span>
</div>
<div class="checkbox-grid">
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
{% for food in group["items"] %}
<label class="check-option">
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }}">
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
<span>{{ food.name }} · {{ food.visibility_label }}</span>
<span>{{ food.name }} · {{ food.visibility_label }} · {{ food.for_label }}</span>
</label>
{% endfor %}
</div>
@@ -117,9 +140,6 @@
{% for category in categories %}
<option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option>
{% endfor %}
{% if form_data.quick_food_category and form_data.quick_food_category not in categories %}
<option value="{{ form_data.quick_food_category }}" selected>{{ form_data.quick_food_category }}</option>
{% endif %}
</select>
</label>
<label class="wide">
+2 -1
View File
@@ -5,7 +5,7 @@
<div>
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
<h1>{{ item_kind_labels[kind] }}</h1>
<p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert und schnell wiederverwendbar.</p>
<p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert, mit einem klaren Blick darauf, für wen etwas gedacht ist.</p>
</div>
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
</section>
@@ -67,6 +67,7 @@
<div class="chip-row">
<span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span>
<span class="chip">{{ item.for_label }}</span>
</div>
<p class="muted">
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}{% if template %}Tagesvorlage bearbeiten{% else %}Neue Tagesvorlage{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Tagesvorlage</p>
<h1>{% if template %}{{ template.name }} bearbeiten{% else %}Tagesvorlage anlegen{% endif %}</h1>
<p class="lead">Gib der Vorlage einen Namen, den du später schnell wiedererkennst. Die Einträge bleiben bewusst einfach und alltagsnah.</p>
</div>
{% if source_date %}
<span class="status-pill">Aus {{ source_date.strftime('%d.%m.%Y') }}</span>
{% endif %}
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<label>
Name der Vorlage
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
<small class="helper-text">Zum Beispiel: Ruhiger Tag, Einfacher Bürotag oder ein ganz eigener Name.</small>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<div class="stack-sections">
{% for section in daypart_sections %}
<fieldset>
<legend>{{ section.daypart.name }}</legend>
<div class="template-search-row">
<label class="wide">
Einträge filtern
<input
type="text"
placeholder="Nach Namen suchen"
data-filter-input
data-filter-target="#day-template-list-{{ section.daypart.id }}"
>
</label>
</div>
{% if section.quick_items %}
<div class="quick-add-row">
{% for item in section.quick_items %}
<label class="quick-select-card" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="daypart_{{ section.daypart.id }}_item_ids" value="{{ item.id }}" {% if item.id in section.selected_ids %}checked{% endif %}>
<span>
<strong>{{ item.name }}</strong>
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</small>
</span>
</label>
{% endfor %}
</div>
{% endif %}
<div class="checkbox-grid template-checkbox-grid" id="day-template-list-{{ section.daypart.id }}">
{% for item in section.list_items %}
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="daypart_{{ section.daypart.id }}_item_ids" value="{{ item.id }}" {% if item.id in section.selected_ids %}checked{% endif %}>
<span>{{ item.name }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
</label>
{% endfor %}
</div>
</fieldset>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
+165
View File
@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}Vorlagen | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Vorlagen</p>
<h1>Bewährtes ruhig wiederverwenden</h1>
<p class="lead">Tagesvorlagen, Wochenvorlagen und kleine Pakete helfen dabei, vertraute Muster mit wenig Tipparbeit erneut zu nutzen.</p>
</div>
<div class="hero-actions">
<a class="button" href="{{ url_for('main.day_template_create') }}">Neue Tagesvorlage</a>
<a class="button secondary" href="{{ url_for('main.week_template_create') }}">Neue Wochenvorlage</a>
<a class="button secondary" href="{{ url_for('main.item_set_create') }}">Neues Paket</a>
</div>
</section>
<section class="panel compact-form-panel">
<form method="get" class="filter-form">
<label class="wide">
Suche
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div class="filter-actions">
<button type="submit">Filtern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurücksetzen</a>
</div>
</form>
</section>
{% if template_hints %}
<section class="panel">
<div class="panel-head">
<h2>Gerade passend</h2>
</div>
<div class="hint-list">
{% for hint in template_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</section>
{% endif %}
<section class="template-library-grid">
<article class="panel">
<div class="panel-head">
<h2>Tagesvorlagen</h2>
<a href="{{ url_for('main.day_template_create') }}">Neu anlegen</a>
</div>
{% if day_templates %}
<div class="stack-sections">
{% for template in day_templates %}
<article class="template-list-card">
<div>
<strong>{{ template.name }}</strong>
{% if template.description %}
<p class="muted">{{ template.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ template.visibility_label }}</span>
<span class="chip status-soft">{{ template.owner_label }}</span>
{% if template.last_used_at %}
<span class="chip">Zuletzt genutzt</span>
{% endif %}
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}">
{{ csrf_input() }}
<input type="hidden" name="target_date" value="{{ today.isoformat() }}">
<button type="submit">Heute anwenden</button>
</form>
{% if template.can_edit %}
<a class="ghost-button" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Noch keine passende Tagesvorlage. Du kannst eine Vorlage direkt neu anlegen oder aus einem Tagesplan speichern.</p>
{% endif %}
</article>
<article class="panel">
<div class="panel-head">
<h2>Wochenvorlagen</h2>
<a href="{{ url_for('main.week_template_create') }}">Neu anlegen</a>
</div>
{% if week_templates %}
<div class="stack-sections">
{% for template in week_templates %}
<article class="template-list-card">
<div>
<strong>{{ template.name }}</strong>
{% if template.description %}
<p class="muted">{{ template.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ template.visibility_label }}</span>
<span class="chip status-soft">{{ template.owner_label }}</span>
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.week_template_apply', template_id=template.id) }}">
{{ csrf_input() }}
<input type="hidden" name="target_week" value="{{ today.isoformat() }}">
<button type="submit">Diese Woche anwenden</button>
</form>
{% if template.can_edit %}
<a class="ghost-button" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Noch keine Wochenvorlage. Eine gute Woche lässt sich später hier ganz leicht wiederverwenden.</p>
{% endif %}
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>Kleine Pakete</h2>
<a href="{{ url_for('main.item_set_create') }}">Neues Paket</a>
</div>
{% if item_sets %}
<div class="stack-sections">
{% for item_set in item_sets %}
<article class="template-list-card">
<div>
<strong>{{ item_set.name }}</strong>
{% if item_set.description %}
<p class="muted">{{ item_set.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ item_set.visibility_label }}</span>
<span class="chip status-soft">{{ item_set.owner_label }}</span>
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.item_set_apply', set_id=item_set.id) }}">
{{ csrf_input() }}
<button type="submit">Auf Einkaufsliste</button>
</form>
{% if item_set.can_edit %}
<a class="ghost-button" href="{{ url_for('main.item_set_edit', set_id=item_set.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Pakete eignen sich gut für kleine Bündel wie schnelles Frühstück, sicherer Snack oder Einkauf für zwei Tage.</p>
{% endif %}
</section>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}{% if item_set %}Paket bearbeiten{% else %}Neues Paket{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Kleines Paket</p>
<h1>{% if item_set %}{{ item_set.name }} bearbeiten{% else %}Paket anlegen{% endif %}</h1>
<p class="lead">Pakete bündeln wiederkehrende Dinge ganz leicht, zum Beispiel schnelles Frühstück, sicherer Snack oder Einkauf für zwei Tage.</p>
</div>
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<label>
Name des Pakets
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<fieldset>
<legend>Einträge auswählen</legend>
<label>
Einträge filtern
<input type="text" placeholder="Nach Namen suchen" data-filter-input data-filter-target="#item-set-list">
</label>
<div class="stack-sections" id="item-set-list">
{% for group in item_groups %}
<div class="component-group">
<div class="panel-head">
<h3>{{ group["title"] }}</h3>
<span>{{ group["items"]|length }} Einträge</span>
</div>
<div class="checkbox-grid">
{% for item in group["items"] %}
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="item_ids" value="{{ item.id }}" {% if item.id in form_data.item_ids %}checked{% endif %}>
<span>{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{% if template %}Wochenvorlage bearbeiten{% else %}Neue Wochenvorlage{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Wochenvorlage</p>
<h1>{% if template %}{{ template.name }} bearbeiten{% else %}Wochenvorlage anlegen{% endif %}</h1>
<p class="lead">Wochenvorlagen bleiben bewusst leicht: pro Wochentag kannst du eine bestehende Tagesvorlage zuordnen oder einen aktuellen Tag als neue Vorlage übernehmen.</p>
</div>
{% if source_week %}
<span class="status-pill">Aus Woche ab {{ source_week.strftime('%d.%m.%Y') }}</span>
{% endif %}
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="source_week" value="{{ form_data.source_week }}">
<label>
Name der Vorlage
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
<small class="helper-text">Ein Name wie Standardwoche, leichte Woche oder etwas ganz Eigenes reicht völlig aus.</small>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<div class="stack-sections">
{% for weekday_index in range(7) %}
<div class="week-template-row">
<div>
<strong>{{ weekday_labels[weekday_index] }}</strong>
<p class="muted">Du kannst eine vorhandene Tagesvorlage auswählen oder den aktuellen Tag aus der Quellwoche übernehmen.</p>
</div>
<label>
Tagesvorlage
<select name="weekday_{{ weekday_index }}_day_template_id">
<option value="">Noch offen</option>
{% for day_template in day_templates %}
<option value="{{ day_template.id }}" {% if form_data.selected_map.get(weekday_index) == day_template.id %}selected{% endif %}>{{ day_template.name }} · {{ day_template.visibility_label }}</option>
{% endfor %}
</select>
</label>
{% if form_data.source_week %}
<label class="inline-check">
<input type="checkbox" name="weekday_{{ weekday_index }}_copy_source" value="1" {% if form_data.copy_from_source.get(weekday_index) %}checked{% endif %}>
<span>Aus Quellwoche als neue Tagesvorlage übernehmen</span>
</label>
{% endif %}
</div>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
-56
View File
@@ -1,56 +0,0 @@
{% extends "base.html" %}
{% block title %}Mehr | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Mehr</p>
<h1>Alles Weitere auf einen Blick</h1>
<p class="lead">Hier liegen die ruhigeren Nebenwege: Lebensmittel, Mahlzeiten, Woche, Profil und die kleinen Einstellungen.</p>
</div>
<div class="intro-pills">
<span class="status-pill">{{ g.user.display_name or g.user.username }}</span>
<span class="status-pill status-soft">{{ role_labels[g.user.role] }}</span>
</div>
</section>
<section class="panel">
<div class="more-link-grid">
<a class="more-link-card" href="{{ url_for('main.item_list', kind='food') }}">
<strong>Lebensmittel</strong>
<small>Persönliche und gemeinsame Einträge ansehen</small>
</a>
<a class="more-link-card" href="{{ url_for('main.item_list', kind='meal') }}">
<strong>Mahlzeiten</strong>
<small>Mahlzeitenideen sammeln und wiederverwenden</small>
</a>
<a class="more-link-card" href="{{ url_for('main.planner') }}">
<strong>Woche</strong>
<small>Die nächsten sieben Tage im Blick behalten</small>
</a>
<a class="more-link-card" href="{{ url_for('main.archive_view') }}">
<strong>Archiv</strong>
<small>Vertraute Dinge leicht wiederfinden</small>
</a>
<a class="more-link-card" href="{{ url_for('auth.profile') }}">
<strong>Mein Profil</strong>
<small>Zugang, Name und Passwort pflegen</small>
</a>
{% if g.user.role == 'admin' %}
<a class="more-link-card" href="{{ url_for('admin.user_list') }}">
<strong>Nutzer verwalten</strong>
<small>Weitere Haushaltsmitglieder verwalten</small>
</a>
{% endif %}
</div>
</section>
<section class="panel">
<div class="more-actions">
<button class="ghost-button" type="button" data-theme-toggle>Modus wechseln</button>
<form method="post" action="{{ url_for('auth.logout') }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button>
</form>
</div>
</section>
{% endblock %}
+119 -37
View File
@@ -14,6 +14,45 @@
</div>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Tagesvorlagen</h2>
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
</div>
{% if day_templates %}
<div class="stack-sections">
{% for template in day_templates %}
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
{{ csrf_input() }}
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
<div class="template-card">
<strong>{{ template.name }}</strong>
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
</div>
<button type="submit">Vorlage anwenden</button>
</form>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
{% endif %}
</article>
{% if day_hints %}
<article class="panel">
<div class="panel-head">
<h2>Heute im Blick</h2>
</div>
<div class="hint-list">
{% for hint in day_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</article>
{% endif %}
</section>
<section class="planner-day-stack">
{% for section in sections %}
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
@@ -33,53 +72,95 @@
</summary>
<div class="day-tile-body">
{% if section.quick_items %}
<div class="quick-add-row">
{% for item in section.quick_items %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
{% if section.balance_suggestion %}
<div class="suggestion-card">
<strong>{{ section.balance_suggestion.text }}</strong>
{% if section.balance_suggestion["items"] %}
<div class="quick-add-row compact-quick-row">
{% for item in section.balance_suggestion["items"] %}
<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="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="quick-add-button compact-button" type="submit">
<span>{{ item.name }}</span>
<small>zuhause vorhanden</small>
</button>
</form>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% if section.meal_candidates %}
<div class="planner-subsection">
<h3>Mahlzeitenideen</h3>
<div class="quick-add-row compact-quick-row">
{% for item in section.meal_candidates %}
<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="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="quick-add-button compact-button" type="submit">
<span>{{ item.name }}</span>
{% if item.availability_state == 'home' %}<small>zuhause vorhanden</small>{% endif %}
</button>
</form>
{% endfor %}
</div>
</div>
{% endif %}
{% if section.recipe_suggestions %}
<div class="planner-subsection">
<h3>Passt gut dazu</h3>
<div class="quick-add-row compact-quick-row">
{% for suggestion in section.recipe_suggestions %}
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
<input type="hidden" name="visibility" value="shared">
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% endfor %}
</div>
</div>
{% endif %}
<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 }}">
</label>
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
{% for item in section.food_candidates %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="quick-add-button" type="submit">
<button class="picker-row" type="submit">
<span>{{ item.name }}</span>
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small>
{% if item.availability_state == 'home' %}<small>zuhause</small>{% endif %}
</button>
</form>
{% endfor %}
</div>
{% endif %}
<form method="post" class="planner-entry-form planner-entry-form-wide">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<label class="wide">
Eintrag hinzufügen
<select name="item_id">
<option value="">Etwas für {{ section.daypart.name }} wählen</option>
{% for item in section.candidates %}
<option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}>
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %}
</option>
{% endfor %}
</select>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if section.default_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="wide">
Notiz
<input type="text" name="note" placeholder="Optional, wenn eine kleine Erinnerung hilft">
</label>
<button type="submit">Eintragen</button>
</form>
</div>
{% if section.entries %}
<div class="planner-entry-list">
@@ -92,6 +173,7 @@
<div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
</div>
</div>
{% if entry.can_edit %}
+56 -3
View File
@@ -4,8 +4,8 @@
<section class="page-intro">
<div>
<p class="eyebrow">Wochenansicht</p>
<h1>Ein sanfter Blick auf die nächsten sieben Tage</h1>
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben. Wenn etwas noch nicht zuhause ist, landet es dabei automatisch auf der Einkaufsliste.</p>
<h1>Ein ruhiger Blick auf die nächsten sieben Tage</h1>
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und gleichzeitig sehen, was erst später für den Einkauf relevant wird.</p>
</div>
<div class="week-nav">
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
@@ -14,6 +14,59 @@
</div>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Wochenvorlagen</h2>
<a href="{{ url_for('main.week_template_create', source_week=week_start.isoformat()) }}">Als Vorlage speichern</a>
</div>
{% if week_templates %}
<div class="stack-sections">
{% for template in week_templates %}
<form method="post" action="{{ url_for('main.week_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
{{ csrf_input() }}
<input type="hidden" name="target_week" value="{{ week_start.isoformat() }}">
<div class="template-card">
<strong>{{ template.name }}</strong>
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
</div>
<button type="submit">Vorlage anwenden</button>
</form>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Wenn eine Woche sich bewährt hat, kannst du sie hier später als Wochenvorlage wiederverwenden.</p>
{% endif %}
</article>
{% if week_hints %}
<article class="panel">
<div class="panel-head">
<h2>Für diese Woche</h2>
</div>
<div class="hint-list">
{% for hint in week_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</article>
{% endif %}
</section>
{% if upcoming_entries %}
<section class="panel">
<div class="panel-head">
<h2>Kommt später zum Einkauf dazu</h2>
<small>{{ household_settings.shopping_prep_days }} Tag{% if household_settings.shopping_prep_days != 1 %}e{% endif %} Vorlauf</small>
</div>
<div class="chip-row">
{% for entry in upcoming_entries %}
<span class="chip">{{ entry.item_name }} · ab {{ entry.activation_label }}</span>
{% endfor %}
</div>
</section>
{% endif %}
<section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}">
{% for card in week_cards %}
<article class="week-card">
@@ -51,7 +104,7 @@
{% for entry in slot.entries %}
<article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
<strong>{{ entry.item_name }}</strong>
<small>{{ entry.visibility_label }} · {{ entry.owner_label }}</small>
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
</article>
{% endfor %}
</div>
+125
View File
@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}Optionen | Nouri{% endblock %}
{% block content %}
<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>
</div>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Einkaufstag</h2>
</div>
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="household">
<label>
Wochentag für den Großeinkauf
<select name="shopping_weekday">
{% for value, label in weekday_options %}
<option value="{{ value }}" {% if household_settings.shopping_weekday == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
So viele Tage vorher vorbereiten
<input type="number" min="0" max="7" name="shopping_prep_days" value="{{ household_settings.shopping_prep_days }}">
</label>
<label>
Erinnerung ungefähr um
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
</label>
<button type="submit">Speichern</button>
</form>
</article>
<article class="panel">
<div class="panel-head">
<h2>Home-Bildschirm & Push</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>
</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>
<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>
</div>
<form method="post">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="push_test">
<button class="ghost-button" type="submit">Test-Mitteilung senden</button>
</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>
{% endif %}
</div>
</div>
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>Erinnerungen und Hinweise</h2>
</div>
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="reminders">
<div class="settings-grid">
<fieldset>
<legend>Grundsätzlich</legend>
<label class="inline-check"><input type="checkbox" name="reminders_enabled" value="1" {% if user_settings.reminders_enabled %}checked{% endif %}><span>Erinnerungen insgesamt nutzen</span></label>
<label class="inline-check"><input type="checkbox" name="push_enabled" value="1" {% if user_settings.push_enabled %}checked{% endif %}><span>Push-Mitteilungen erlauben</span></label>
<label>
Hinweise zeigen als
<select name="notification_channel">
{% for value, label in notification_channel_options %}
<option value="{{ value }}" {% if user_settings.notification_channel == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<fieldset>
<legend>Einkauf</legend>
<label class="inline-check"><input type="checkbox" name="remind_before_shopping" value="1" {% if user_settings.remind_before_shopping %}checked{% endif %}><span>Am Tag vor dem Einkauf erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="remind_on_shopping_day" value="1" {% if user_settings.remind_on_shopping_day %}checked{% endif %}><span>Am Einkaufstag erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="show_missing_for_upcoming_week" value="1" {% if user_settings.show_missing_for_upcoming_week %}checked{% endif %}><span>Fehlende Dinge für die kommende Woche zeigen</span></label>
<label class="inline-check"><input type="checkbox" name="show_planned_not_shopped" value="1" {% if user_settings.show_planned_not_shopped %}checked{% endif %}><span>Geplante, aber noch nicht eingekaufte Dinge zeigen</span></label>
</fieldset>
<fieldset>
<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="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>
</fieldset>
<fieldset>
<legend>Alltag</legend>
<label class="inline-check"><input type="checkbox" name="remind_small_snack" value="1" {% if user_settings.remind_small_snack %}checked{% endif %}><span>An kleine Zwischenmahlzeiten erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="remind_nuts" value="1" {% if user_settings.remind_nuts %}checked{% endif %}><span>Heute schon an Nüsse gedacht?</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_templates" value="1" {% if user_settings.suggest_templates %}checked{% endif %}><span>Häufig genutzte Tages- und Wochenvorlagen vorschlagen</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_patterns" value="1" {% if user_settings.suggest_patterns %}checked{% endif %}><span>Wiederkehrende Muster vorschlagen</span></label>
</fieldset>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('auth.profile') }}">Zum Profil</a>
</div>
</form>
</section>
{% endblock %}
+42 -3
View File
@@ -5,7 +5,7 @@
<div>
<p class="eyebrow">Einkaufsliste</p>
<h1>Was noch mitkommen soll</h1>
<p class="lead">Abhaken legt Dinge automatisch unter Zuhause ab. Gemeinsame und persönliche Einträge bleiben dabei klar erkennbar.</p>
<p class="lead">Hier erscheint, was für den nächsten Einkauf wirklich relevant ist. Spätere Bedarfe bleiben erstmal ruhig vorgemerkt.</p>
</div>
</section>
@@ -26,15 +26,28 @@
</section>
{% if entries %}
<section class="panel compact-form-panel">
<div class="panel-head">
<h2>Für den nächsten Einkauf</h2>
<span>{{ entries|length }} Einträge</span>
</div>
</section>
<section class="stack-list">
{% for entry in entries %}
<article class="list-row stacked-mobile">
<article class="list-row stacked-mobile roomy-row">
<div>
<strong>{{ entry.item_name }}</strong>
<p class="muted">{{ item_kind_labels[entry.item_kind] }}</p>
<div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
{% if entry.needed_for_label %}
<span class="chip status-home">
Für {{ entry.needed_for_label }}
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
</span>
{% endif %}
</div>
</div>
<div class="row-actions">
@@ -55,7 +68,33 @@
{% else %}
<section class="panel empty-panel">
<h2>Die Liste ist gerade frei</h2>
<p>Einträge aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufügen.</p>
<p>Einträge aus Lebensmitteln, Vorlagen oder kleinen Paketen lassen sich jederzeit wieder hinzufügen.</p>
</section>
{% endif %}
{% if upcoming_entries %}
<section class="panel">
<div class="panel-head">
<h2>Später gebraucht</h2>
<small>Einkaufstag: {{ shopping_weekday_label }}</small>
</div>
<div class="stack-list">
{% for entry in upcoming_entries %}
<article class="list-row stacked-mobile roomy-row">
<div>
<strong>{{ entry.item_name }}</strong>
<p class="muted">Wird ab {{ entry.activation_label }} in die Einkaufsliste übernommen</p>
<div class="chip-row">
<span class="chip">{{ entry.for_label }}</span>
<span class="chip">{{ entry.needed_for_label }}</span>
{% if entry.needed_daypart_name %}
<span class="chip status-soft">{{ entry.needed_daypart_name }}</span>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
</section>
{% endif %}
{% endblock %}
+1
View File
@@ -1,2 +1,3 @@
Flask==3.1.1
gunicorn==23.0.0
pywebpush==2.3.0
+26
View File
@@ -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()
-1
View File
@@ -3,7 +3,6 @@ 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"
exec gunicorn \