release nouri 0.5.0 shopping rhythm pwa and reminders

This commit is contained in:
2026-04-12 16:39:04 +02:00
parent d8b56e6b67
commit 96ab52e1ba
37 changed files with 2199 additions and 285 deletions
+25
View File
@@ -11,13 +11,18 @@ from . import db
from .admin import admin_bp
from .auth import auth_bp
from .constants import (
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)
@@ -78,11 +87,19 @@ def create_app() -> Flask:
"item_kind_labels": ITEM_KIND_LABELS,
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
"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
+35 -6
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 DEFAULT_CATEGORIES, ROLE_LABELS
from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS
from .db import get_db
@@ -197,6 +197,7 @@ def user_edit(user_id: int):
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:
@@ -210,8 +211,8 @@ def category_settings():
).fetchone()
if existing:
get_db().execute(
"UPDATE household_categories SET is_active = 1 WHERE id = ?",
(existing["id"],),
"UPDATE household_categories SET is_active = 1, builder_key = ? WHERE id = ?",
(builder_key, existing["id"]),
)
flash("Die Kategorie ist wieder aktiv.", "success")
else:
@@ -221,10 +222,10 @@ def category_settings():
).fetchone()
get_db().execute(
"""
INSERT INTO household_categories (household_id, name, sort_order, is_active)
VALUES (?, ?, ?, 1)
INSERT INTO household_categories (household_id, name, builder_key, sort_order, is_active)
VALUES (?, ?, ?, ?, 1)
""",
(g.user["household_id"], name, int(sort_row["max_sort"]) + 10),
(g.user["household_id"], name, builder_key, int(sort_row["max_sort"]) + 10),
)
flash("Die Kategorie wurde ergänzt.", "success")
get_db().commit()
@@ -234,6 +235,9 @@ def category_settings():
"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,
)
@@ -260,3 +264,28 @@ def category_toggle(category_id: int):
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"))
+51
View File
@@ -20,6 +20,57 @@ DEFAULT_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",
+117 -4
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, DEFAULT_CATEGORIES
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
def get_db() -> sqlite3.Connection:
@@ -53,6 +53,9 @@ 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
)
"""
@@ -64,6 +67,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
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,
@@ -72,6 +76,14 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
"""
)
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")
@@ -92,6 +104,82 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
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")
@@ -126,10 +214,18 @@ def sync_default_categories(database: sqlite3.Connection) -> None:
for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10):
database.execute(
"""
INSERT OR IGNORE INTO household_categories (household_id, name, sort_order, is_active)
VALUES (?, ?, ?, 1)
INSERT OR IGNORE INTO household_categories (household_id, name, builder_key, sort_order, is_active)
VALUES (?, ?, ?, ?, 1)
""",
(household_id, name, sort_order),
(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),
)
@@ -141,6 +237,11 @@ 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,),
@@ -204,6 +305,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
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(
"""
@@ -236,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:
+963 -187
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
+65
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
);
@@ -28,6 +31,7 @@ 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,
@@ -35,6 +39,41 @@ CREATE TABLE IF NOT EXISTS household_categories (
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,
@@ -106,6 +145,29 @@ 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,
@@ -218,6 +280,9 @@ 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);
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

+174 -9
View File
@@ -128,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;
@@ -445,6 +468,7 @@ h3 {
justify-content: space-between;
gap: 1rem;
align-items: center;
padding: 1rem 1.1rem;
}
.stacked-mobile {
@@ -616,6 +640,7 @@ input[type="text"],
input[type="email"],
input[type="password"],
input[type="date"],
input[type="time"],
input[type="file"],
select,
textarea {
@@ -862,6 +887,111 @@ legend {
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;
}
@@ -1018,6 +1148,41 @@ legend {
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;
@@ -1054,17 +1219,10 @@ legend {
.mobile-sheet-links {
display: grid;
gap: 0.45rem;
gap: 0.75rem;
margin: 1rem 0;
}
.mobile-sheet-links a {
padding: 0.9rem 1rem;
border-radius: 16px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-strong) 86%, #fff 14%);
}
.mobile-sheet-actions {
flex-wrap: wrap;
}
@@ -1091,6 +1249,7 @@ legend {
.stats-grid,
.two-column,
.template-library-grid,
.settings-grid,
.inline-form,
.planner-entry-form,
.planner-entry-form-wide,
@@ -1158,6 +1317,10 @@ legend {
padding: 1rem;
}
.site-footer {
padding-bottom: 5.6rem;
}
h1 {
font-size: clamp(1.6rem, 7vw, 2rem);
}
@@ -1174,7 +1337,9 @@ legend {
.week-mini-grid,
.week-overview-grid,
.more-link-grid,
.template-library-grid {
.template-library-grid,
.settings-grid,
.card-link-grid {
grid-template-columns: 1fr;
}
+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

+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

+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();
});
});
}
});
})();
+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"
}
]
}
+74
View File
@@ -0,0 +1,74 @@
const CACHE_NAME = "nouri-v0-5-0";
const APP_SHELL = [
"/",
"/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(APP_SHELL)).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;
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;
})
);
});
+25 -1
View File
@@ -17,6 +17,14 @@
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>
@@ -26,8 +34,12 @@
<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 %}</p>
<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 %}
@@ -36,6 +48,18 @@
</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">
+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">
+30 -10
View File
@@ -4,12 +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">
@@ -33,12 +41,13 @@
<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.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-layer-group"></span><span>Vorlagen</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>
@@ -71,6 +80,16 @@
{% 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 %}
@@ -83,16 +102,17 @@
</div>
<button class="ghost-button" type="button" data-mobile-sheet-close>Schließen</button>
</div>
<nav class="mobile-sheet-links">
<a href="{{ url_for('main.item_list', kind='food') }}">Lebensmittel</a>
<a href="{{ url_for('main.item_list', kind='meal') }}">Mahlzeiten</a>
<a href="{{ url_for('main.home_view') }}">Zuhause</a>
<a href="{{ url_for('main.archive_view') }}">Archiv</a>
<a href="{{ url_for('main.template_library') }}">Vorlagen</a>
<a href="{{ url_for('auth.profile') }}">Profil</a>
<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 href="{{ url_for('admin.user_list') }}">Nutzerverwaltung</a>
<a href="{{ url_for('admin.category_settings') }}">Kategorien</a>
<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">
+48 -3
View File
@@ -5,7 +5,7 @@
<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, welche Vorlagen gut passen und wo sanfte Unterstützung hilfreich sein kann.</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>
@@ -34,7 +34,7 @@
{% if dashboard_hints %}
<section class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
<h2>Heute passend</h2>
</div>
<div class="hint-list">
{% for hint in dashboard_hints %}
@@ -105,6 +105,28 @@
</section>
<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>
@@ -129,6 +151,29 @@
<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">
@@ -144,7 +189,7 @@
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
{% else %}
<span>Noch frei</span>
<small>sanfter Einstieg für den Tag</small>
<small>ruhiger Einstieg für den Tag</small>
{% endif %}
</a>
{% endfor %}
+20
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"] %}
+1 -1
View File
@@ -38,7 +38,7 @@
{% if template_hints %}
<section class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
<h2>Gerade passend</h2>
</div>
<div class="hint-list">
{% for hint in template_hints %}
+77 -47
View File
@@ -42,7 +42,7 @@
{% if day_hints %}
<article class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
<h2>Heute im Blick</h2>
</div>
<div class="hint-list">
{% for hint in day_hints %}
@@ -72,65 +72,95 @@
</summary>
<div class="day-tile-body">
{% if section.suggestions %}
<div class="suggestion-row">
{% for suggestion in section.suggestions %}
<article class="suggestion-card">
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
<p>{{ suggestion.note }}</p>
</article>
{% endfor %}
{% 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.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.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 }} · {{ item.for_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 }} · {{ item.for_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">
+17 -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, Vorlagen anwenden und eine Woche bei Bedarf für später sichern.</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>
@@ -42,7 +42,7 @@
{% if week_hints %}
<article class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
<h2>Für diese Woche</h2>
</div>
<div class="hint-list">
{% for hint in week_hints %}
@@ -53,6 +53,20 @@
{% 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">
+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 %}
+34 -2
View File
@@ -5,7 +5,7 @@
<div>
<p class="eyebrow">Einkaufsliste</p>
<h1>Was noch mitkommen soll</h1>
<p class="lead">Fehlende Lebensmittel aus einer Mahlzeit landen jetzt einzeln hier. So bleibt die Liste konkret und alltagsnah.</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,9 +26,15 @@
</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>
@@ -65,4 +71,30 @@
<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 %}