12 Commits

31 changed files with 2406 additions and 130 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz", "author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning", "description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen", "tagline": "einfach essen planen",
"version": "1.0.0", "version": "1.2.1",
"upstreamVersion": "1.0.0", "upstreamVersion": "1.2.1",
"healthCheckPath": "/", "healthCheckPath": "/",
"httpPort": 8000, "httpPort": 8000,
"manifestVersion": 2, "manifestVersion": 2,
+53
View File
@@ -0,0 +1,53 @@
# Nouri 1.1.1
Nouri 1.1.1 bündelt die jüngsten Verbesserungen rund um Mahlzeiten-Vorschläge, Plan-Einträge, Push-Erinnerungen und den letzten Feinschliff bei Bezeichnungen und Versionierung. Der Release macht die App im Alltag direkter nutzbar und runder im Verhalten.
## Highlights
- generierte Mahlzeiten lassen sich pro Nutzer dauerhaft ausblenden
- vorhandene Mahlzeiten mit nur 1 bis 2 fehlenden Zutaten werden jetzt ebenfalls vorgeschlagen
- einzelne Plan-Einträge können nachträglich für `Für mich` oder `Gemeinsam` angepasst werden
- Frühstück-, Mittag- und Abend-Erinnerungen arbeiten zuverlässiger über echte Zeitfenster
- Begriffe wie `Mahlzeitideen` werden wieder korrekt großgeschrieben
- App- und Cloudron-Version stehen jetzt auf `1.1.1`
## Neu in 1.1.1
### Mahlzeiten und Vorschläge
- Im Bereich `Was zuhause gut zusammenpasst` werden die Aktionsbuttons wieder korrekt dargestellt.
- Generierte Mahlzeiten können mit `Dauerhaft ausblenden` pro Nutzer aus den Vorschlägen entfernt werden.
- Nouri zeigt jetzt nicht nur vollständige Kombinationen aus Zuhause an.
- Auch vorhandene Mahlzeitenideen mit nur 1 oder 2 fehlenden Lebensmitteln werden vorgeschlagen.
- Fehlende Dinge werden direkt kenntlich gemacht, zum Beispiel mit `Es fehlt noch: ...`.
### Plan und Tagesansicht
- Ein einzelner Planeintrag kann jetzt im Tagesplan direkt angepasst werden.
- So lässt sich zum Beispiel ein geplanter Snack nachträglich nur für eine Person setzen, ohne die Grundeinstellungen der Mahlzeit oder des Lebensmittels zu ändern.
- Die Anzeige `Für mich`, `Für alle` und persönliche Zuordnungen ist in diesem Zusammenhang klarer geworden.
### Push und Erinnerungen
- Die zeitgesteuerten Erinnerungen für fehlendes Frühstück, Mittagessen und Abendessen laufen nicht mehr nur in einem sehr kleinen Zeitfenster.
- Stattdessen nutzt Nouri jetzt breitere Zeitfenster:
- Frühstück ab `08:00`
- Mittagessen ab `12:00`
- Abendessen ab `18:00`
- Dadurch greifen die normalen Erinnerungen deutlich zuverlässiger, auch wenn der Reminder-Worker nicht exakt in derselben Minute läuft.
### Oberfläche
- Die automatisch kleingeschriebene Anzeige im Tagesplan wurde korrigiert.
- Begriffe wie `Mahlzeitideen` erscheinen wieder so, wie sie in der App gedacht sind.
### Versionierung
- `CloudronManifest.json` wurde auf `1.1.1` angehoben.
- Der interne Versions-Fallback in `nouri/__init__.py` wurde ebenfalls auf `1.1.1` gesetzt.
- Die Schema-Version in `nouri/db.py` folgt jetzt ebenfalls `1.1.1`.
## Cloudron
- Das Update kann sauber als neue Version ausgerollt werden.
- Footer, Release-Link und Versionsanzeige greifen damit wieder auf einen konsistenten Stand zu.
+60
View File
@@ -0,0 +1,60 @@
# Nouri 1.2.0
Nouri 1.2.0 bündelt die Weiterentwicklung seit 1.1.1 zu einem ruhigeren und alltagstauglicheren Planungs-Release. Der Fokus lag auf weniger Überforderung im Plan, besseren kleinen Erinnerungen und einem sauberen PDF-Export für den Wochenplan.
## Neu in 1.2.0
### Snacks ruhiger im Tages- und Wochenplan
- Hauptmahlzeiten bleiben immer sichtbar.
- Snack-Bereiche werden nur bei Bedarf eingeblendet.
- Leere Snack-Slots lassen sich wieder ausblenden.
- In der Wochenansicht wurden die Snack-Aktionen sprachlich gestrafft:
- `Snacks ergänzen`
- `Vormittag`
- `Nachmittag`
- `Abend`
### Bessere visuelle Betonung im Plan
- Ausgewählte und eingetragene Mahlzeiten werden im Tagesplan klarer hervorgehoben.
- Die Wochenansicht betont gefüllte Slots jetzt ähnlich wie die Tagesansicht.
- Snack-Slots fügen sich in der Wochenansicht stimmiger ein und wirken ruhiger.
### Kleine tägliche Snack-Erinnerung
- Neue Option in den Einstellungen:
- `Am Nachmittag an etwas Kleines erinnern`
- Wenn noch kein Snack geplant ist, kann Nouri einmal täglich eine kleine Push-Erinnerung schicken.
- Die Push-Nachricht nimmt zuerst passende Mahlzeitenideen und sonst einfache Kombinationen aus dem, was zuhause da ist.
### Wochenplan als PDF exportieren
- Die Wochenansicht kann jetzt als PDF exportiert werden.
- Der Export ist schlicht und druckfreundlich gehalten.
- Es gibt zwei Varianten:
- `Meinen Essensplan`
- `Unseren Essensplan`
- Im gemeinsamen PDF werden persönliche Einträge mit echten Namen gekennzeichnet, zum Beispiel `Für Flo`.
- Snack-Zeilen erscheinen nur dann, wenn sie in der Woche tatsächlich genutzt werden.
### Export-Menü vereinfacht
- Statt zwei einzelner Export-Buttons gibt es jetzt einen einzigen Button:
- `PDF exportieren`
- Darunter öffnet sich eine kleine Auswahl für die beiden PDF-Varianten.
## Technische Änderungen
- `fpdf2` wurde als Abhängigkeit ergänzt.
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.2.0`.
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.2.0` angehoben.
## Betroffene Bereiche
- Tagesplan
- Wochenansicht
- Push-Erinnerungen
- Einstellungen
- PDF-Export
- Cloudron-Paketierung
+43
View File
@@ -0,0 +1,43 @@
# Nouri 1.2.1
Nouri 1.2.1 ist ein Feinschliff-Release auf Basis von 1.2.0. Der Schwerpunkt lag auf einer ruhigeren mobilen Navigation, klareren Theme-Bedienelementen und besser lesbaren Tageszeiten-Icons in Hell und Dunkel.
## Neu in 1.2.1
### Mobile Navigation ruhiger abgestimmt
- Der `Mehr`-Button in der mobilen Bottom-Navigation bleibt im Ruhezustand jetzt neutral.
- Wenn `Mehr` geöffnet ist, nutzt der Button dieselbe aktive Markierung wie die übrige Navigation.
- `Hell` und `Abmelden` wirken im mobilen Dark Mode zurückhaltender und sind nicht mehr unnötig stark eingefärbt.
### Besseres Theme-Umschalten
- Für den Wechsel zwischen Hell und Dunkel gibt es jetzt eigene Sonne- und Mond-Icons.
- Die Theme-Anzeige schaltet in Mobile und Desktop sichtbar mit um.
- Die Bedienelemente für den Darstellungswechsel wirken dadurch klarer und weniger technisch.
### Eigene Icons für Tageszeiten
- `Frühstück`, `Mittagessen`, `Abendessen` und die Snack-Zeiten haben jetzt eigene Symbole statt eines gemeinsamen Standardsymbols.
- Die Icons wurden aus `heinz.marketing` übernommen und lokal ins Projekt eingebunden.
- Dadurch sind Tageszeiten im Tagesplan und in der Wochenansicht schneller erfassbar.
### Icons auf Mobile lesbarer gemacht
- Die Tageszeiten-Kacheln nutzen jetzt quadratischere Icon-Flächen mit abgerundeten Ecken.
- Die Symbole wurden vergrößert und farblich klarer abgestimmt.
- Im Dark Mode wirken die Icon-Flächen weniger verwaschen.
- Im Light Mode wurde der Kontrast erhöht, damit die Symbole nicht mehr im Kartenhintergrund verschwinden.
## Technische Änderungen
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.2.1`.
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.2.1` angehoben.
## Betroffene Bereiche
- Mobile Navigation
- Theme-Umschaltung
- Tagesplan
- Wochenansicht
- Cloudron-Paketierung
+41 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import secrets import secrets
from datetime import date, timedelta from datetime import date, timedelta
@@ -34,6 +35,15 @@ from .main import main_bp
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases"
DAYPART_ICON_CLASSES = {
"breakfast": "icon-daypart-breakfast",
"morning-snack": "icon-daypart-morning-snack",
"lunch": "icon-daypart-lunch",
"afternoon-snack": "icon-daypart-afternoon-snack",
"dinner": "icon-daypart-dinner",
"late-snack": "icon-daypart-late-snack",
}
def load_secret_key(data_dir: Path) -> str: def load_secret_key(data_dir: Path) -> str:
@@ -54,11 +64,38 @@ def load_secret_key(data_dir: Path) -> str:
return secret_value return secret_value
def load_app_version(root_dir: Path) -> str:
env_version = os.environ.get("NOURI_APP_VERSION", "").strip()
if env_version:
return env_version
manifest_path = root_dir / "CloudronManifest.json"
if manifest_path.exists():
try:
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
manifest_data = {}
manifest_version = str(
manifest_data.get("upstreamVersion")
or manifest_data.get("version")
or ""
).strip()
if manifest_version:
return manifest_version
return "1.2.1"
def load_release_url() -> str:
return os.environ.get("NOURI_RELEASE_URL", DEFAULT_RELEASE_URL).strip() or DEFAULT_RELEASE_URL
def create_app() -> Flask: def create_app() -> Flask:
root_dir = Path(__file__).resolve().parent.parent root_dir = Path(__file__).resolve().parent.parent
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve() data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
upload_dir = data_dir / "uploads" upload_dir = data_dir / "uploads"
db_path = data_dir / "nouri.sqlite3" db_path = data_dir / "nouri.sqlite3"
app_version = load_app_version(root_dir)
release_url = load_release_url()
data_dir.mkdir(parents=True, exist_ok=True) data_dir.mkdir(parents=True, exist_ok=True)
ensure_upload_structure(upload_dir) ensure_upload_structure(upload_dir)
@@ -74,7 +111,8 @@ def create_app() -> Flask:
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1", SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
APP_VERSION="1.0.0", APP_VERSION=app_version,
RELEASE_URL=release_url,
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"), TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""), VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""), VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
@@ -114,10 +152,12 @@ def create_app() -> Flask:
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS, "notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
"today": date.today(), "today": date.today(),
"app_version": app.config["APP_VERSION"], "app_version": app.config["APP_VERSION"],
"app_release_url": app.config["RELEASE_URL"],
"push_public_key": app.config["VAPID_PUBLIC_KEY"], "push_public_key": app.config["VAPID_PUBLIC_KEY"],
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]), "push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
"daypart_icon_class": lambda slug: DAYPART_ICON_CLASSES.get(slug, "icon-calendar"),
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin", "is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
"asset_url": asset_url, "asset_url": asset_url,
"image_url": lambda filename, variant="md": image_url( "image_url": lambda filename, variant="md": image_url(
+28
View File
@@ -289,3 +289,31 @@ def category_update(category_id: int):
get_db().commit() get_db().commit()
flash("Die Zuordnung wurde aktualisiert.", "success") flash("Die Zuordnung wurde aktualisiert.", "success")
return redirect(url_for("admin.category_settings")) return redirect(url_for("admin.category_settings"))
@admin_bp.post("/categories/<int:category_id>/delete")
@admin_required
def category_delete(category_id: int):
category = get_db().execute(
"""
SELECT *
FROM household_categories
WHERE id = ? AND household_id = ?
""",
(category_id, g.user["household_id"]),
).fetchone()
if category is None:
flash("Die Kategorie wurde nicht gefunden.", "error")
return redirect(url_for("admin.category_settings"))
if category["name"] in DEFAULT_CATEGORIES:
flash("Standardkategorien bleiben erhalten. Du kannst sie bei Bedarf pausieren.", "info")
return redirect(url_for("admin.category_settings"))
get_db().execute(
"DELETE FROM household_categories WHERE id = ? AND household_id = ?",
(category_id, g.user["household_id"]),
)
get_db().commit()
flash("Die Kategorie wurde entfernt.", "success")
return redirect(url_for("admin.category_settings"))
+26 -1
View File
@@ -10,7 +10,7 @@ from werkzeug.security import generate_password_hash
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
CURRENT_SCHEMA_VERSION = "1.0.0" CURRENT_SCHEMA_VERSION = "1.2.1"
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
@@ -18,9 +18,11 @@ def get_db() -> sqlite3.Connection:
g.db = sqlite3.connect( g.db = sqlite3.connect(
current_app.config["DATABASE_PATH"], current_app.config["DATABASE_PATH"],
detect_types=sqlite3.PARSE_DECLTYPES, detect_types=sqlite3.PARSE_DECLTYPES,
timeout=30,
) )
g.db.row_factory = sqlite3.Row g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA foreign_keys = ON") g.db.execute("PRAGMA foreign_keys = ON")
g.db.execute("PRAGMA busy_timeout = 30000")
return g.db return g.db
@@ -160,6 +162,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
push_missing_breakfast INTEGER NOT NULL DEFAULT 0, push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
push_missing_lunch INTEGER NOT NULL DEFAULT 0, push_missing_lunch INTEGER NOT NULL DEFAULT 0,
push_missing_dinner INTEGER NOT NULL DEFAULT 0, push_missing_dinner INTEGER NOT NULL DEFAULT 0,
push_small_snack INTEGER NOT NULL DEFAULT 0,
suggest_home_for_today INTEGER NOT NULL DEFAULT 1, suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
remind_small_snack INTEGER NOT NULL DEFAULT 0, remind_small_snack INTEGER NOT NULL DEFAULT 0,
remind_nuts INTEGER NOT NULL DEFAULT 0, remind_nuts INTEGER NOT NULL DEFAULT 0,
@@ -203,6 +206,19 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
""" """
) )
database.execute(
"""
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
suggestion_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, suggestion_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
database.execute( database.execute(
""" """
CREATE TABLE IF NOT EXISTS shopping_needs ( CREATE TABLE IF NOT EXISTS shopping_needs (
@@ -241,6 +257,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_small_snack INTEGER NOT NULL DEFAULT 0")
def ensure_default_household(database: sqlite3.Connection) -> int: def ensure_default_household(database: sqlite3.Connection) -> int:
@@ -366,6 +383,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_small_snack INTEGER NOT NULL DEFAULT 0")
if default_owner_id is not None: if default_owner_id is not None:
database.execute( database.execute(
@@ -416,6 +434,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL") database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL") database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL") database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")
database.execute("UPDATE user_settings SET push_small_snack = 0 WHERE push_small_snack IS NULL")
database.execute( database.execute(
""" """
@@ -454,6 +473,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
ON shopping_needs (household_id, activation_date, is_activated) ON shopping_needs (household_id, activation_date, is_activated)
""" """
) )
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_hidden_generated_suggestions_user
ON hidden_generated_suggestions (user_id)
"""
)
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION) set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
+583 -44
View File
@@ -2,8 +2,10 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from io import BytesIO
from itertools import product from itertools import product
from pathlib import Path from pathlib import Path
import sqlite3
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -71,6 +73,13 @@ VISIBILITY_FORM_OPTIONS = [
] ]
TARGET_USER_OPTIONS_DEFAULT = "__all__" TARGET_USER_OPTIONS_DEFAULT = "__all__"
WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
PRIMARY_DAYPART_SLUGS = {"breakfast", "lunch", "dinner"}
SNACK_DAYPART_SLUGS = {"morning-snack", "afternoon-snack", "late-snack"}
PDF_DAYPART_LABELS = {
"morning-snack": "Snack am Vormittag",
"afternoon-snack": "Snack am Nachmittag",
"late-snack": "Später Snack",
}
@main_bp.before_app_request @main_bp.before_app_request
@@ -79,8 +88,10 @@ def refresh_due_context():
if getattr(g, "user", None) is None: if getattr(g, "user", None) is None:
return None return None
if request.method == "GET" and endpoint.startswith("main."): if request.method == "GET" and endpoint.startswith("main."):
ensure_user_settings_row() try:
activate_due_shopping_needs() activate_due_shopping_needs()
except sqlite3.OperationalError:
current_app.logger.warning("Due shopping needs could not be activated during this request.")
return None return None
@@ -92,6 +103,10 @@ def get_dayparts() -> list:
return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall() return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall()
def format_weekday(day_value: date) -> str:
return WEEKDAY_LABELS[day_value.weekday()]
def get_household_users(active_only: bool = True): def get_household_users(active_only: bool = True):
query = """ query = """
SELECT id, username, display_name, role SELECT id, username, display_name, role
@@ -172,19 +187,55 @@ def get_household_settings() -> dict:
} }
def ensure_user_settings_row() -> None: def default_user_settings() -> dict:
suggestion_style = "balanced"
return {
"user_id": int(g.user["id"]),
"reminders_enabled": True,
"push_enabled": False,
"notification_channel": "in_app",
"suggestion_style": suggestion_style,
"energy_preference": suggestion_style_energy_preference(suggestion_style),
"remind_before_shopping": True,
"remind_on_shopping_day": True,
"show_missing_for_upcoming_week": True,
"show_planned_not_shopped": True,
"remind_tomorrow_if_sparse": True,
"remind_week_if_sparse": True,
"push_missing_breakfast": False,
"push_missing_lunch": False,
"push_missing_dinner": False,
"push_small_snack": False,
"suggest_home_for_today": True,
"remind_small_snack": False,
"remind_nuts": False,
"show_meal_balancing": True,
"suggest_templates": True,
"suggest_patterns": True,
}
def ensure_user_settings_row(*, commit: bool = False) -> None:
existing = get_db().execute(
"SELECT 1 FROM user_settings WHERE user_id = ? LIMIT 1",
(g.user["id"],),
).fetchone()
if existing is not None:
return
get_db().execute( get_db().execute(
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)", "INSERT INTO user_settings (user_id) VALUES (?)",
(g.user["id"],), (g.user["id"],),
) )
if commit:
get_db().commit()
def get_user_settings() -> dict: def get_user_settings() -> dict:
ensure_user_settings_row() settings = default_user_settings()
row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone() row = get_db().execute("SELECT * FROM user_settings WHERE user_id = ?", (g.user["id"],)).fetchone()
if row is None: if row is None:
return {} return settings
settings = dict(row) settings.update(dict(row))
boolean_fields = { boolean_fields = {
"reminders_enabled", "reminders_enabled",
"push_enabled", "push_enabled",
@@ -197,6 +248,7 @@ def get_user_settings() -> dict:
"push_missing_breakfast", "push_missing_breakfast",
"push_missing_lunch", "push_missing_lunch",
"push_missing_dinner", "push_missing_dinner",
"push_small_snack",
"suggest_home_for_today", "suggest_home_for_today",
"remind_small_snack", "remind_small_snack",
"remind_nuts", "remind_nuts",
@@ -319,7 +371,12 @@ def describe_record(entry: dict) -> dict:
entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam") entry["visibility_label"] = VISIBILITY_LABELS.get(entry.get("visibility"), "Gemeinsam")
entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "") entry["visibility_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "")
entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}" entry["owner_label"] = "Von mir" if entry["is_mine"] else f"Von {owner_name}"
entry["for_label"] = f"Für {target_name}" if target_name else "Für alle" if target_name:
entry["for_label"] = f"Für {target_name}"
elif entry["is_personal"]:
entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}"
else:
entry["for_label"] = "Für alle"
entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin" entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
return entry return entry
@@ -1229,6 +1286,23 @@ def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]:
return tuple(sorted({int(component_id) for component_id in component_ids})) return tuple(sorted({int(component_id) for component_id in component_ids}))
def generated_suggestion_key(component_ids: list[int]) -> str:
signature = normalized_component_signature(component_ids)
return "generated:" + "-".join(str(component_id) for component_id in signature)
def fetch_hidden_generated_suggestion_keys() -> set[str]:
rows = get_db().execute(
"""
SELECT suggestion_key
FROM hidden_generated_suggestions
WHERE user_id = ?
""",
(g.user["id"],),
).fetchall()
return {row["suggestion_key"] for row in rows}
def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str: def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
names = [item["name"] for item in combo] names = [item["name"] for item in combo]
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2: if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} and len(names) >= 2:
@@ -1239,63 +1313,90 @@ def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]: def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
builder_groups: dict[str, list[dict]] = defaultdict(list)
for food in home_foods:
for builder_key in food.get("builder_keys", ["neutral"]):
builder_groups[builder_key].append(food)
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}: if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
target_patterns = [ target_patterns = [
("carb", "dairy", "fruit"), {
("carb", "dairy", "nuts"), "slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
("carb", "dairy", "seeds"), "reason": "Passt gut zu Frühstück oder Snack",
("carb", "fruit", "dairy"), },
{
"slots": ({"carb"}, {"dairy", "protein"}),
"reason": "Zuhause schnell kombinierbar",
},
{
"slots": ({"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
"reason": "Lässt sich gut als kleiner Snack vormerken",
},
] ]
reasons = {
("carb", "dairy", "fruit"): "Passt gut zu Frühstück oder Snack",
("carb", "dairy", "nuts"): "Lässt sich gut für einen Snack vormerken",
("carb", "dairy", "seeds"): "Lässt sich gut für einen Snack vormerken",
("carb", "fruit", "dairy"): "Zuhause gut kombinierbar",
}
else: else:
target_patterns = [ target_patterns = [
("protein", "carb", "veg"), {
("protein", "carb"), "slots": ({"protein"}, {"carb"}, {"veg"}),
"reason": "Zuhause als vollständige Mahlzeit möglich",
},
{
"slots": ({"protein"}, {"carb"}),
"reason": "Lässt sich leicht ergänzen",
},
{
"slots": ({"protein"}, {"veg"}),
"reason": "Zuhause schon gut kombinierbar",
},
{
"slots": ({"carb"}, {"veg"}),
"reason": "Daraus kann schnell etwas Einfaches werden",
},
] ]
reasons = {
("protein", "carb", "veg"): "Zuhause als vollständige Mahlzeit möglich",
("protein", "carb"): "Lässt sich leicht ergänzen",
}
suggestions: list[dict] = [] suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set() seen_signatures: set[tuple[int, ...]] = set()
def slot_matches(food: dict, slot_keys: set[str]) -> bool:
return bool(slot_keys & set(food.get("builder_keys", ["neutral"])))
for pattern in target_patterns: for pattern in target_patterns:
groups = [builder_groups.get(builder_key, []) for builder_key in pattern] slot_candidates = []
if any(not group for group in groups): for slot_keys in pattern["slots"]:
matches = [food for food in home_foods if slot_matches(food, slot_keys)]
if not matches:
slot_candidates = []
break
slot_candidates.append(matches)
if not slot_candidates:
continue continue
for combo in product(*groups):
for combo in product(*slot_candidates):
signature = normalized_component_signature([item["id"] for item in combo]) signature = normalized_component_signature([item["id"] for item in combo])
if len(signature) != len(pattern) or signature in seen_signatures: if len(signature) != len(combo) or signature in seen_signatures:
continue continue
seen_signatures.add(signature) seen_signatures.add(signature)
combo_items = list(combo) combo_items = list(combo)
suggestions.append( suggestions.append(
{ {
"title": build_generated_meal_name(combo_items, daypart_slug), "title": build_generated_meal_name(combo_items, daypart_slug),
"reason": reasons.get(pattern, "Zuhause gut kombinierbar"), "reason": pattern["reason"],
"component_ids": [item["id"] for item in combo_items], "component_ids": [item["id"] for item in combo_items],
"existing_item_id": None, "existing_item_id": None,
"visibility": "shared",
"daypart_id": None,
"missing_component_ids": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": True,
"suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]),
} }
) )
if len(suggestions) >= limit: if len(suggestions) >= limit * 3:
return suggestions break
if len(suggestions) >= limit * 3:
break
return suggestions return suggestions
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]: def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
settings = get_user_settings() settings = get_user_settings()
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "") daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
hidden_keys = fetch_hidden_generated_suggestion_keys()
home_foods = [ home_foods = [
item item
for item in fetch_items(kind="food", availability="home") for item in fetch_items(kind="food", availability="home")
@@ -1303,30 +1404,76 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
] ]
home_food_ids = {item["id"] for item in home_foods} home_food_ids = {item["id"] for item in home_foods}
home_food_map = {int(item["id"]): item for item in home_foods} home_food_map = {int(item["id"]): item for item in home_foods}
visible_foods = [
item
for item in fetch_items(kind="food", include_archived=False)
if item_matches_daypart(item, daypart_id)
]
visible_food_map = {int(item["id"]): item for item in visible_foods}
suggestions: list[dict] = [] suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set() seen_signatures: set[tuple[int, ...]] = set()
meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)] meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)]
for meal in meals: for meal in meals:
if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]): if not meal["component_ids"]:
signature = normalized_component_signature(meal["component_ids"]) continue
component_ids = [int(component_id) for component_id in meal["component_ids"]]
if not all(component_id in visible_food_map for component_id in component_ids):
continue
signature = normalized_component_signature(component_ids)
if signature in seen_signatures: if signature in seen_signatures:
continue continue
component_items = [visible_food_map[component_id] for component_id in component_ids]
available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map]
missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids]
if not available_items:
continue
if missing_items and len(missing_items) > 2:
continue
seen_signatures.add(signature) seen_signatures.add(signature)
component_items = [home_food_map[component_id] for component_id in meal["component_ids"] if component_id in home_food_map] if missing_items:
missing_names = [item["name"] for item in missing_items]
suggestions.append(
{
"title": meal["name"],
"reason": f"Es fehlt noch: {', '.join(missing_names)}",
"component_ids": component_ids,
"existing_item_id": meal["id"],
"visibility": meal["visibility"],
"daypart_id": daypart_id or meal.get("primary_daypart_id"),
"missing_component_ids": [item["id"] for item in missing_items],
"missing_components": missing_names,
"needs_shopping": True,
"is_generated": False,
"suggestion_key": None,
"score": score_suggestion_components(available_items, daypart_slug=daypart_slug, settings=settings) + 18 - (len(missing_items) * 4),
}
)
else:
suggestions.append( suggestions.append(
{ {
"title": meal["name"], "title": meal["name"],
"reason": "Zuhause vorhanden", "reason": "Zuhause vorhanden",
"component_ids": meal["component_ids"], "component_ids": component_ids,
"existing_item_id": meal["id"], "existing_item_id": meal["id"],
"visibility": meal["visibility"],
"daypart_id": daypart_id or meal.get("primary_daypart_id"),
"missing_component_ids": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": False,
"suggestion_key": None,
"score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40, "score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40,
} }
) )
for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2): for suggestion in build_dynamic_meal_suggestions(home_foods, daypart_slug, limit=limit * 2):
signature = normalized_component_signature(suggestion["component_ids"]) signature = normalized_component_signature(suggestion["component_ids"])
if signature in seen_signatures: if signature in seen_signatures or suggestion["suggestion_key"] in hidden_keys:
continue continue
seen_signatures.add(signature) seen_signatures.add(signature)
component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map] component_items = [home_food_map[component_id] for component_id in suggestion["component_ids"] if component_id in home_food_map]
@@ -1403,6 +1550,13 @@ def build_daypart_suggestions(daypart_id: int) -> list[dict]:
"reason": "Für später vormerken", "reason": "Für später vormerken",
"component_ids": [], "component_ids": [],
"existing_item_id": item["id"] if item["kind"] == "meal" else None, "existing_item_id": item["id"] if item["kind"] == "meal" else None,
"visibility": item["visibility"],
"daypart_id": daypart_id,
"missing_component_ids": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": False,
"suggestion_key": None,
} }
for item in archived_items[:2] for item in archived_items[:2]
] ]
@@ -1676,7 +1830,7 @@ def build_selected_quick_action(
return { return {
"type": "existing", "type": "existing",
"title": selected_item["name"], "title": selected_item["name"],
"subtitle": "Bereit zum Eintragen", "subtitle": "Ausgewählt. Du kannst es jetzt direkt eintragen.",
"item_id": int(selected_item["id"]), "item_id": int(selected_item["id"]),
"visibility": selected_item["visibility"], "visibility": selected_item["visibility"],
"daypart_id": daypart_id, "daypart_id": daypart_id,
@@ -1686,7 +1840,7 @@ def build_selected_quick_action(
return { return {
"type": "generated", "type": "generated",
"title": selected_meal_name, "title": selected_meal_name,
"subtitle": "Vorgeschlagen aus dem, was zuhause da ist", "subtitle": "Ausgewählt aus dem, was zuhause gut passt.",
"component_ids": selected_component_ids, "component_ids": selected_component_ids,
"visibility": "shared", "visibility": "shared",
"daypart_id": daypart_id, "daypart_id": daypart_id,
@@ -1719,6 +1873,7 @@ def build_day_planner_sections(
+ [item for item in candidates if item["kind"] == "food"], + [item for item in candidates if item["kind"] == "food"],
limit=20, limit=20,
) )
search_candidates = dedupe_items(meal_candidates + food_candidates, limit=24)
entry_item_ids = [int(entry["item_id"]) for entry in entries] entry_item_ids = [int(entry["item_id"]) for entry in entries]
sections.append( sections.append(
{ {
@@ -1727,6 +1882,7 @@ def build_day_planner_sections(
"candidates": candidates, "candidates": candidates,
"meal_candidates": meal_candidates, "meal_candidates": meal_candidates,
"food_candidates": food_candidates, "food_candidates": food_candidates,
"search_candidates": search_candidates,
"recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3), "recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3),
"suggestions": build_daypart_suggestions(daypart["id"]), "suggestions": build_daypart_suggestions(daypart["id"]),
"balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids), "balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids),
@@ -1739,6 +1895,9 @@ def build_day_planner_sections(
candidates=candidates, candidates=candidates,
), ),
"is_open": selected_daypart_id == daypart["id"], "is_open": selected_daypart_id == daypart["id"],
"is_primary_daypart": daypart["slug"] in PRIMARY_DAYPART_SLUGS,
"is_snack_daypart": daypart["slug"] in SNACK_DAYPART_SLUGS,
"visible_by_default": daypart["slug"] in PRIMARY_DAYPART_SLUGS or bool(entries) or selected_daypart_id == daypart["id"],
"summary_items": [entry["item_name"] for entry in entries][:2], "summary_items": [entry["item_name"] for entry in entries][:2],
"default_visibility": "shared", "default_visibility": "shared",
} }
@@ -1770,7 +1929,20 @@ def build_template_day_sections(selected_map: dict[int, list[int]] | None = None
def fetch_week_cards(week_start: date): def fetch_week_cards(week_start: date):
days = [week_start + timedelta(days=index) for index in range(7)] days = [week_start + timedelta(days=index) for index in range(7)]
week_end = week_start + timedelta(days=6)
grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6)) grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6))
picker_map = {}
for daypart in get_dayparts():
candidates = fetch_plan_candidates(int(daypart["id"]))
meal_candidates = dedupe_items(
[item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"]
+ [item for item in candidates if item["kind"] == "meal"],
limit=4,
)
picker_map[int(daypart["id"])] = {
"meal_candidates": meal_candidates,
"recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3),
}
cards = [] cards = []
for current_day in days: for current_day in days:
filled_dayparts = [] filled_dayparts = []
@@ -1779,11 +1951,27 @@ def fetch_week_cards(week_start: date):
slots = [] slots = []
for daypart in get_dayparts(): for daypart in get_dayparts():
slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), []) slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), [])
slots.append({"daypart": dict(daypart), "entries": slot_entries}) is_snack_daypart = daypart["slug"] in SNACK_DAYPART_SLUGS
visible_by_default = (not is_snack_daypart) or bool(slot_entries)
slots.append(
{
"daypart": dict(daypart),
"entries": slot_entries,
"copy_allowed": bool(slot_entries) and current_day < week_end,
"picker": picker_map.get(int(daypart["id"]), {"meal_candidates": [], "recipe_suggestions": []}),
"is_snack_daypart": is_snack_daypart,
"visible_by_default": visible_by_default,
}
)
if slot_entries: if slot_entries:
filled_dayparts.append({"id": daypart["id"], "name": daypart["name"], "count": len(slot_entries)}) filled_dayparts.append({"id": daypart["id"], "name": daypart["name"], "count": len(slot_entries)})
planned_count += len(slot_entries) planned_count += len(slot_entries)
preview_items.extend(entry["item_name"] for entry in slot_entries[:2]) preview_items.extend(entry["item_name"] for entry in slot_entries[:2])
hidden_snack_slots = [
{"id": int(slot["daypart"]["id"]), "name": slot["daypart"]["name"]}
for slot in slots
if slot["is_snack_daypart"] and not slot["visible_by_default"]
]
cards.append( cards.append(
{ {
"date": current_day, "date": current_day,
@@ -1791,11 +1979,179 @@ def fetch_week_cards(week_start: date):
"planned_count": planned_count, "planned_count": planned_count,
"preview_items": preview_items[:4], "preview_items": preview_items[:4],
"slots": slots, "slots": slots,
"hidden_snack_slots": hidden_snack_slots,
} }
) )
return cards return cards
def format_week_pdf_entry(entry: dict) -> str:
return entry["item_name"]
def normalize_pdf_export_mode(raw: str | None) -> str:
return "household" if raw == "household" else "mine"
def fetch_plan_entries_for_range_export(start_date: date, end_date: date, *, mode: str):
params: list[object] = [start_date.isoformat(), end_date.isoformat()]
if mode == "household":
where_clause = "plan_entries.household_id = ?"
params.append(current_household_id())
else:
where_clause = visible_clause("plan_entries")
params.extend(visible_params())
rows = get_db().execute(
f"""
SELECT plan_entries.*,
items.name AS item_name,
items.kind AS item_kind,
items.photo_filename,
items.availability_state,
dayparts.name AS daypart_name,
dayparts.slug AS daypart_slug,
dayparts.sort_order,
owner.display_name AS owner_display_name,
owner.username AS owner_username,
target.display_name AS target_display_name,
target.username AS target_username
FROM plan_entries
JOIN items ON items.id = plan_entries.item_id
JOIN dayparts ON dayparts.id = plan_entries.daypart_id
LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id
LEFT JOIN users AS target ON target.id = items.target_user_id
WHERE plan_date BETWEEN ? AND ? AND {where_clause}
ORDER BY plan_date, dayparts.sort_order, items.name
""",
params,
).fetchall()
grouped = defaultdict(list)
for row in describe_records(rows):
grouped[(row["plan_date"], row["daypart_id"])].append(row)
return grouped
def format_week_pdf_entry(entry: dict, *, mode: str) -> str:
label = format_pdf_cell_label(entry["item_name"])
if mode == "household":
if entry.get("target_name"):
return f"{label} (Für {entry['target_name']})"
if entry.get("is_personal"):
return f"{label} (Für {entry['owner_name']})"
return f"{label} (Für alle)"
if entry.get("is_shared"):
return f"{label} (gemeinsam)"
return label
def format_pdf_cell_label(label: str) -> str:
cleaned = " ".join((label or "").split())
if not cleaned:
return ""
if " - " in cleaned:
return cleaned.replace(" - ", "\n")
if ", " in cleaned and len(cleaned) > 20:
return cleaned.replace(", ", ",\n")
if "-" in cleaned:
return "-\n".join(part for part in cleaned.split("-") if part)
return cleaned
def build_week_pdf_rows(week_start: date, *, mode: str) -> tuple[list, list[list[str]]]:
days = [week_start + timedelta(days=index) for index in range(7)]
grouped_entries = fetch_plan_entries_for_range_export(week_start, week_start + timedelta(days=6), mode=mode)
rows: list[list[str]] = []
dayparts = get_dayparts()
visible_dayparts = []
for daypart in dayparts:
row_cells: list[str] = []
has_content = False
row_label = PDF_DAYPART_LABELS.get(daypart["slug"], daypart["name"])
row = [daypart["name"]]
for current_day in days:
entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), [])
cell_value = "\n".join(format_week_pdf_entry(entry, mode=mode) for entry in entries)
row_cells.append(cell_value)
has_content = has_content or bool(cell_value.strip())
if daypart["slug"] in PRIMARY_DAYPART_SLUGS or has_content:
visible_dayparts.append([row_label, *row_cells])
return days, visible_dayparts
def build_week_plan_pdf(week_start: date, *, mode: str = "mine") -> bytes:
try:
from fpdf import FPDF
from fpdf.fonts import FontFace
except ImportError as exc: # pragma: no cover - depends on optional package in local env
raise RuntimeError("Für den PDF-Export fehlt noch die Abhängigkeit aus der requirements.txt.") from exc
mode = normalize_pdf_export_mode(mode)
week_end = week_start + timedelta(days=6)
week_number = week_start.isocalendar().week
days, rows = build_week_pdf_rows(week_start, mode=mode)
plan_label = "Mein Essensplan" if mode == "mine" else "Unser Essensplan"
pdf = FPDF(orientation="L", unit="mm", format="A4")
pdf.set_auto_page_break(auto=True, margin=14)
pdf.set_margins(left=14, top=14, right=14)
pdf.add_page()
pdf.set_title(f"{plan_label} KW {week_number:02d}")
pdf.set_author("Nouri")
pdf.set_creator("Nouri")
pdf.set_font("Helvetica", "B", 18)
pdf.cell(0, 9, f"{plan_label} vom {week_start.strftime('%d.%m.%Y')} bis {week_end.strftime('%d.%m.%Y')}", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 11)
pdf.set_text_color(82, 82, 82)
pdf.cell(0, 6, f"KW {week_number:02d}", new_x="LMARGIN", new_y="NEXT")
pdf.ln(3)
pdf.set_text_color(20, 20, 20)
pdf.set_font("Helvetica", "", 10)
headings = [" "] + [f"{format_weekday(day)}\n{day.strftime('%d.%m.%Y')}" for day in days]
first_column_width = 34
remaining_width = pdf.w - pdf.l_margin - pdf.r_margin - first_column_width
day_column_width = remaining_width / 7
column_widths = (first_column_width, *([day_column_width] * 7))
header_style = FontFace(emphasis="B", fill_color=(240, 240, 240))
first_column_style = FontFace(emphasis="B", fill_color=(248, 248, 248))
body_style = FontFace(fill_color=(255, 255, 255))
with pdf.table(
borders_layout="SINGLE_TOP_LINE",
cell_fill_color=(255, 255, 255),
cell_fill_mode="ROWS",
col_widths=column_widths,
gutter_height=0,
gutter_width=0,
headings_style=header_style,
line_height=5.5,
text_align=("LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT"),
width=pdf.w - pdf.l_margin - pdf.r_margin,
) as table:
header_row = table.row()
for heading in headings:
header_row.cell(heading, padding=(2.8, 2.5, 2.8, 2.5), v_align="M")
for row in rows:
table_row = table.row()
table_row.cell(row[0], style=first_column_style, padding=(2.5, 2.8, 2.5, 2.8), v_align="M")
for value in row[1:]:
table_row.cell(value or " ", style=body_style, padding=(2.5, 2.8, 2.5, 2.8), v_align="TOP")
return bytes(pdf.output())
def count_visible_items(availability_state: str) -> int: def count_visible_items(availability_state: str) -> int:
row = get_db().execute( row = get_db().execute(
f"SELECT COUNT(*) AS count FROM items WHERE availability_state = ? AND {visible_clause('items')}", f"SELECT COUNT(*) AS count FROM items WHERE availability_state = ? AND {visible_clause('items')}",
@@ -2338,6 +2694,23 @@ def insert_plan_entry(*, item_id: int, daypart_id: int, plan_date: date, visibil
) )
def update_plan_entry(entry_id: int, *, visibility: str, note: str) -> None:
get_db().execute(
"""
UPDATE plan_entries
SET visibility = ?,
owner_user_id = CASE
WHEN ? = 'personal' THEN ?
ELSE owner_user_id
END,
note = ?
WHERE id = ?
""",
(visibility, visibility, g.user["id"], note, entry_id),
)
get_db().commit()
def planner_template_options(): def planner_template_options():
return fetch_day_templates() return fetch_day_templates()
@@ -2398,6 +2771,25 @@ def template_library():
) )
@main_bp.post("/suggestions/hide")
@login_required
def suggestion_hide():
component_ids = [int(value) for value in request.form.getlist("component_ids") if value.isdigit()]
if not component_ids:
flash("Diese Kombination konnte gerade nicht ausgeblendet werden.", "error")
return redirect(request.referrer or url_for("main.dashboard"))
get_db().execute(
"""
INSERT OR IGNORE INTO hidden_generated_suggestions (user_id, suggestion_key)
VALUES (?, ?)
""",
(g.user["id"], generated_suggestion_key(component_ids)),
)
get_db().commit()
flash("Diese generierte Mahlzeit wird dir künftig nicht mehr vorgeschlagen.", "info")
return redirect(request.referrer or url_for("main.dashboard"))
@main_bp.route("/templates/day/new", methods=("GET", "POST")) @main_bp.route("/templates/day/new", methods=("GET", "POST"))
@login_required @login_required
def day_template_create(): def day_template_create():
@@ -2749,6 +3141,7 @@ def settings_view():
push_missing_breakfast = ?, push_missing_breakfast = ?,
push_missing_lunch = ?, push_missing_lunch = ?,
push_missing_dinner = ?, push_missing_dinner = ?,
push_small_snack = ?,
suggest_home_for_today = ?, suggest_home_for_today = ?,
remind_small_snack = ?, remind_small_snack = ?,
remind_nuts = ?, remind_nuts = ?,
@@ -2773,6 +3166,7 @@ def settings_view():
parse_checkbox("push_missing_breakfast", False), parse_checkbox("push_missing_breakfast", False),
parse_checkbox("push_missing_lunch", False), parse_checkbox("push_missing_lunch", False),
parse_checkbox("push_missing_dinner", False), parse_checkbox("push_missing_dinner", False),
parse_checkbox("push_small_snack", False),
parse_checkbox("suggest_home_for_today", True), parse_checkbox("suggest_home_for_today", True),
parse_checkbox("remind_small_snack", False), parse_checkbox("remind_small_snack", False),
parse_checkbox("remind_nuts", False), parse_checkbox("remind_nuts", False),
@@ -3421,6 +3815,29 @@ def planner():
week_hints=build_week_hints(week_start), week_hints=build_week_hints(week_start),
upcoming_entries=fetch_upcoming_shopping_needs(limit=8), upcoming_entries=fetch_upcoming_shopping_needs(limit=8),
household_settings=get_household_settings(), household_settings=get_household_settings(),
visibility_options=VISIBILITY_FORM_OPTIONS,
)
@main_bp.get("/planner/export.pdf")
@login_required
def planner_export_pdf():
week_start = parse_week_start(request.args.get("week"))
mode = normalize_pdf_export_mode(request.args.get("mode"))
try:
pdf_bytes = build_week_plan_pdf(week_start, mode=mode)
except RuntimeError as exc:
flash(str(exc), "error")
return redirect(url_for("main.planner", week=week_start.isoformat()))
week_number = week_start.isocalendar().week
prefix = "mein-essensplan" if mode == "mine" else "unser-essensplan"
filename = f"{prefix}-kw-{week_number:02d}-{week_start.year}.pdf"
return send_file(
BytesIO(pdf_bytes),
mimetype="application/pdf",
as_attachment=True,
download_name=filename,
) )
@@ -3521,10 +3938,48 @@ def planner_generated_meal():
return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}") return redirect(f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}")
@main_bp.post("/planner/<int:entry_id>/update")
@login_required
def planner_update(entry_id: int):
selected_date = parse_plan_date(request.form.get("plan_date"))
return_week = request.form.get("return_week", "").strip()
entry = get_db().execute(
f"""
SELECT plan_entries.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM plan_entries
LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id
WHERE plan_entries.id = ? AND {visible_clause('plan_entries')}
""",
[entry_id, *visible_params()],
).fetchone()
if entry is None:
flash("Der Planeintrag wurde nicht gefunden.", "error")
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
try:
ensure_can_edit(describe_record(dict(entry)), "Diesen Planeintrag kannst du gerade nicht bearbeiten.")
except PermissionError as exc:
flash(str(exc), "error")
if return_week:
return redirect(url_for("main.planner", week=return_week))
return redirect(url_for("main.planner_day", date=selected_date.isoformat()))
visibility = normalize_visibility(request.form.get("visibility"), entry["visibility"])
note = request.form.get("note", "").strip()
update_plan_entry(entry_id, visibility=visibility, note=note)
flash("Der Planeintrag wurde angepasst.", "success")
if return_week:
return redirect(url_for("main.planner", week=return_week))
return redirect(url_for("main.planner_day", date=selected_date.isoformat(), daypart_id=entry["daypart_id"]))
@main_bp.post("/planner/<int:entry_id>/remove") @main_bp.post("/planner/<int:entry_id>/remove")
@login_required @login_required
def planner_remove(entry_id: int): def planner_remove(entry_id: int):
selected_date = request.args.get("date", "") selected_date = request.args.get("date", "") or request.form.get("plan_date", "").strip()
return_week = request.form.get("return_week", "").strip()
entry = get_db().execute( entry = get_db().execute(
f""" f"""
SELECT plan_entries.*, SELECT plan_entries.*,
@@ -3546,6 +4001,8 @@ def planner_remove(entry_id: int):
flash("Der Planeintrag wurde entfernt.", "info") flash("Der Planeintrag wurde entfernt.", "info")
except PermissionError as exc: except PermissionError as exc:
flash(str(exc), "error") flash(str(exc), "error")
if return_week:
return redirect(url_for("main.planner", week=return_week))
if selected_date: if selected_date:
return redirect(url_for("main.planner_day", date=selected_date)) return redirect(url_for("main.planner_day", date=selected_date))
return redirect(url_for("main.planner")) return redirect(url_for("main.planner"))
@@ -3603,3 +4060,85 @@ def planner_move(entry_id: int):
"redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()), "redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()),
} }
) )
@main_bp.post("/planner/slot/copy-forward")
@login_required
def planner_slot_copy_forward():
source_date = parse_plan_date(request.form.get("source_date"))
target_date = source_date + timedelta(days=1)
daypart_raw = request.form.get("daypart_id", "").strip()
if not daypart_raw.isdigit():
flash("Die Tageszeit konnte nicht erkannt werden.", "error")
return redirect(url_for("main.planner", week=parse_week_start(source_date.isoformat()).isoformat()))
daypart_id = int(daypart_raw)
entries = get_db().execute(
f"""
SELECT plan_entries.*,
owner.display_name AS owner_display_name,
owner.username AS owner_username
FROM plan_entries
LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id
WHERE plan_entries.plan_date = ? AND plan_entries.daypart_id = ? AND {visible_clause('plan_entries')}
ORDER BY plan_entries.id
""",
[source_date.isoformat(), daypart_id, *visible_params()],
).fetchall()
copied_count = 0
shopping_added = 0
shopping_scheduled = 0
for raw_entry in entries:
entry = describe_record(dict(raw_entry))
try:
ensure_can_edit(entry, "Diesen Planeintrag kannst du gerade nicht kopieren.")
except PermissionError:
continue
duplicate = get_db().execute(
"""
SELECT id
FROM plan_entries
WHERE household_id = ?
AND plan_date = ?
AND daypart_id = ?
AND item_id = ?
AND visibility = ?
AND COALESCE(note, '') = COALESCE(?, '')
LIMIT 1
""",
(
current_household_id(),
target_date.isoformat(),
daypart_id,
entry["item_id"],
entry["visibility"],
entry.get("note", ""),
),
).fetchone()
if duplicate:
continue
shopping_result = insert_plan_entry(
item_id=entry["item_id"],
daypart_id=daypart_id,
plan_date=target_date,
visibility=entry["visibility"],
note=entry.get("note", "") or "",
)
copied_count += 1
shopping_added += int(shopping_result["count"])
shopping_scheduled += int(shopping_result["scheduled_count"])
if copied_count == 0:
flash("Für diese Tageszeit gab es nichts Neues zum Kopieren.", "info")
else:
if shopping_added:
flash("Fehlende Lebensmittel wurden für den passenden Einkauf ergänzt.", "info")
elif shopping_scheduled:
flash("Fehlende Lebensmittel wurden für später vorgemerkt.", "info")
flash(f"{copied_count} Eintrag{' wurde' if copied_count == 1 else 'e wurden'} zum nächsten Tag kopiert.", "success")
return redirect(url_for("main.planner", week=parse_week_start(source_date.isoformat()).isoformat()))
+100 -8
View File
@@ -13,11 +13,20 @@ from .push import send_push_message
MEAL_PUSH_RULES = [ MEAL_PUSH_RULES = [
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "label": "Frühstück"}, {"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "end_hour": 12, "label": "Frühstück"},
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "label": "Mittagessen"}, {"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "end_hour": 18, "label": "Mittagessen"},
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "label": "Abendessen"}, {"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "end_hour": 24, "label": "Abendessen"},
] ]
SNACK_PUSH_RULE = {
"slugs": ("morning-snack", "afternoon-snack", "late-snack"),
"setting": "push_small_snack",
"hour": 15,
"minute": 0,
"end_hour": 20,
"label": "Etwas Kleines",
}
def current_local_time() -> datetime: def current_local_time() -> datetime:
timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin") timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin")
@@ -73,6 +82,24 @@ def plan_exists_for_daypart(user, *, planned_date: date, daypart_id: int) -> boo
return bool(int(row["count"] or 0)) return bool(int(row["count"] or 0))
def plan_exists_for_any_daypart(user, *, planned_date: date, daypart_ids: list[int]) -> bool:
if not daypart_ids:
return False
placeholders = ", ".join("?" for _ in daypart_ids)
row = get_db().execute(
f"""
SELECT COUNT(*) AS count
FROM plan_entries
WHERE household_id = ?
AND plan_date = ?
AND daypart_id IN ({placeholders})
AND (visibility = 'shared' OR owner_user_id = ?)
""",
[int(user["household_id"]), planned_date.isoformat(), *daypart_ids, int(user["id"])],
).fetchone()
return bool(int(row["count"] or 0))
def reminder_event_exists(user_id: int, event_key: str) -> bool: def reminder_event_exists(user_id: int, event_key: str) -> bool:
row = get_db().execute( row = get_db().execute(
"SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1", "SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1",
@@ -92,10 +119,11 @@ def mark_reminder_event(user_id: int, event_key: str) -> None:
get_db().commit() get_db().commit()
def due_for_rule(now: datetime, *, hour: int, minute: int) -> bool: def due_for_rule(now: datetime, *, hour: int, minute: int, end_hour: int) -> bool:
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0) current_minutes = (now.hour * 60) + now.minute
delta = (now - target).total_seconds() target_minutes = (hour * 60) + minute
return 0 <= delta < 180 end_minutes = end_hour * 60
return target_minutes <= current_minutes < end_minutes
def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str: def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str:
@@ -118,6 +146,13 @@ def build_push_message(label: str, suggestion: dict | None) -> tuple[str, str]:
return title, f"Für {label.lower()} ist noch nichts geplant." return title, f"Für {label.lower()} ist noch nichts geplant."
def build_small_snack_push_message(suggestion: dict | None) -> tuple[str, str]:
title = "Nouri · Etwas Kleines"
if suggestion and suggestion.get("title"):
return title, f"Für später wäre etwas Kleines möglich. Zuhause passt gerade: {suggestion['title']}."
return title, "Für später wäre etwas Kleines möglich. Vielleicht passt heute etwas Einfaches wie Nüsse oder ein Apfel."
def best_suggestion_for_user(user, daypart_id: int) -> dict | None: def best_suggestion_for_user(user, daypart_id: int) -> dict | None:
previous_user = getattr(g, "user", None) previous_user = getattr(g, "user", None)
g.user = user g.user = user
@@ -128,6 +163,19 @@ def best_suggestion_for_user(user, daypart_id: int) -> dict | None:
return suggestions[0] if suggestions else None return suggestions[0] if suggestions else None
def best_small_snack_suggestion_for_user(user, daypart_ids: list[int]) -> tuple[int | None, dict | None]:
previous_user = getattr(g, "user", None)
g.user = user
try:
for daypart_id in daypart_ids:
suggestions = build_home_recipe_suggestions(daypart_id, limit=1)
if suggestions:
return daypart_id, suggestions[0]
finally:
g.user = previous_user
return (daypart_ids[0] if daypart_ids else None), None
def send_due_meal_pushes(now: datetime | None = None) -> int: def send_due_meal_pushes(now: datetime | None = None) -> int:
now = now or current_local_time() now = now or current_local_time()
planned_date = now.date() planned_date = now.date()
@@ -155,7 +203,7 @@ def send_due_meal_pushes(now: datetime | None = None) -> int:
for rule in MEAL_PUSH_RULES: for rule in MEAL_PUSH_RULES:
if not settings.get(rule["setting"]): if not settings.get(rule["setting"]):
continue continue
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"]): if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"], end_hour=rule["end_hour"]):
continue continue
daypart = dayparts.get(rule["slug"]) daypart = dayparts.get(rule["slug"])
@@ -189,6 +237,50 @@ def send_due_meal_pushes(now: datetime | None = None) -> int:
mark_reminder_event(int(user["id"]), event_key) mark_reminder_event(int(user["id"]), event_key)
sent_count += 1 sent_count += 1
snack_rule = SNACK_PUSH_RULE
if settings.get(snack_rule["setting"]) and due_for_rule(
now,
hour=snack_rule["hour"],
minute=snack_rule["minute"],
end_hour=snack_rule["end_hour"],
):
snack_daypart_ids = [
int(dayparts[slug]["id"])
for slug in snack_rule["slugs"]
if slug in dayparts
]
if snack_daypart_ids and not plan_exists_for_any_daypart(
user,
planned_date=planned_date,
daypart_ids=snack_daypart_ids,
):
event_key = f"meal-push:{planned_date.isoformat()}:small-snack"
if not reminder_event_exists(int(user["id"]), event_key):
daypart_id, suggestion = best_small_snack_suggestion_for_user(user, snack_daypart_ids)
title, body = build_small_snack_push_message(suggestion)
url = build_push_target_url(
planned_date=planned_date,
daypart_id=daypart_id or snack_daypart_ids[0],
suggestion=suggestion,
)
delivered = False
for subscription in subscriptions:
ok, _error = send_push_message(
{
"endpoint": subscription["endpoint"],
"keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]},
},
title=title,
body=body,
url=url,
)
delivered = delivered or ok
if delivered:
mark_reminder_event(int(user["id"]), event_key)
sent_count += 1
return sent_count return sent_count
+10
View File
@@ -61,6 +61,7 @@ CREATE TABLE IF NOT EXISTS user_settings (
push_missing_breakfast INTEGER NOT NULL DEFAULT 0, push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
push_missing_lunch INTEGER NOT NULL DEFAULT 0, push_missing_lunch INTEGER NOT NULL DEFAULT 0,
push_missing_dinner INTEGER NOT NULL DEFAULT 0, push_missing_dinner INTEGER NOT NULL DEFAULT 0,
push_small_snack INTEGER NOT NULL DEFAULT 0,
suggest_home_for_today INTEGER NOT NULL DEFAULT 1, suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
remind_small_snack INTEGER NOT NULL DEFAULT 0, remind_small_snack INTEGER NOT NULL DEFAULT 0,
remind_nuts INTEGER NOT NULL DEFAULT 0, remind_nuts INTEGER NOT NULL DEFAULT 0,
@@ -94,6 +95,15 @@ CREATE TABLE IF NOT EXISTS reminder_events (
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
suggestion_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, suggestion_key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dayparts ( CREATE TABLE IF NOT EXISTS dayparts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE, slug TEXT NOT NULL UNIQUE,
+751 -21
View File
@@ -89,7 +89,8 @@ textarea {
} }
button, button,
.button { .button,
.ghost-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -100,7 +101,7 @@ button,
background: var(--accent); background: var(--accent);
color: white; color: white;
cursor: pointer; cursor: pointer;
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease; transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
} }
button:focus-visible, button:focus-visible,
@@ -123,19 +124,64 @@ button:hover,
.button.secondary, .button.secondary,
button.secondary, button.secondary,
.ghost-button { .ghost-button {
background: transparent; background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-soft) 72%, #fff 28%),
color-mix(in srgb, var(--surface-strong) 82%, #fff 18%)
);
color: var(--text); color: var(--text);
border-color: var(--line); border-color: color-mix(in srgb, var(--accent) 34%, var(--line) 66%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.8),
0 8px 20px rgba(225, 181, 138, 0.12);
} }
.button.secondary:hover, .button.secondary:hover,
button.secondary:hover, button.secondary:hover,
.ghost-button:hover { .ghost-button:hover {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent-soft) 58%, #fff 42%),
color-mix(in srgb, var(--surface-soft) 88%, #fff 12%)
);
border-color: color-mix(in srgb, var(--accent) 46%, var(--line) 54%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.84),
0 12px 28px rgba(212, 155, 104, 0.16);
}
[data-theme="dark"] button:not(.secondary):not(.ghost-button),
[data-theme="dark"] .button:not(.secondary):not(.ghost-button) {
background: #d7935f;
color: #201a17;
border-color: rgba(243, 177, 125, 0.28);
}
[data-theme="dark"] button:not(.secondary):not(.ghost-button):hover,
[data-theme="dark"] .button:not(.secondary):not(.ghost-button):hover {
background: #e0a270;
color: #181311;
}
[data-theme="dark"] .button.secondary,
[data-theme="dark"] button.secondary,
[data-theme="dark"] .ghost-button {
background: transparent;
color: var(--text);
border-color: var(--line);
box-shadow: none;
}
[data-theme="dark"] .button.secondary:hover,
[data-theme="dark"] button.secondary:hover,
[data-theme="dark"] .ghost-button:hover {
background: var(--accent-soft); background: var(--accent-soft);
border-color: rgba(243, 177, 125, 0.2);
box-shadow: none;
} }
.page-shell { .page-shell {
width: min(1320px, calc(100% - 2rem)); width: min(1680px, calc(100% - 2rem));
margin: 1rem auto 2rem; margin: 1rem auto 2rem;
} }
@@ -232,6 +278,7 @@ h3,
} }
.site-nav a { .site-nav a {
flex: 0 0 auto;
padding: 0.55rem 0.85rem; padding: 0.55rem 0.85rem;
border-radius: 999px; border-radius: 999px;
color: var(--muted); color: var(--muted);
@@ -264,6 +311,8 @@ h3,
column-gap: 1.5rem; column-gap: 1.5rem;
row-gap: 0.9rem; row-gap: 0.9rem;
align-items: center; align-items: center;
padding-left: 1rem;
padding-right: 1rem;
} }
.desktop-header-main { .desktop-header-main {
@@ -278,6 +327,7 @@ h3,
grid-column: 2; grid-column: 2;
grid-row: 1; grid-row: 1;
display: flex; display: flex;
gap: 0.3rem;
flex-wrap: nowrap; flex-wrap: nowrap;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
@@ -308,6 +358,10 @@ h3,
.desktop-actions > * { .desktop-actions > * {
white-space: nowrap; white-space: nowrap;
} }
.desktop-nav a {
padding: 0.5rem 0.74rem;
}
} }
.user-chip, .user-chip,
@@ -395,6 +449,14 @@ h3,
linear-gradient(180deg, color-mix(in srgb, var(--surface) 86%, #fff 14%), color-mix(in srgb, var(--surface) 80%, #ffe5d2 20%)); linear-gradient(180deg, color-mix(in srgb, var(--surface) 86%, #fff 14%), color-mix(in srgb, var(--surface) 80%, #ffe5d2 20%));
} }
[data-theme="dark"] .hero {
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.06), transparent 42%),
linear-gradient(180deg, rgba(64, 55, 52, 0.98), rgba(49, 42, 39, 0.99));
border-color: rgba(243, 177, 125, 0.14);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.24);
}
.hero h1, .hero h1,
.page-intro h1, .page-intro h1,
.panel h2 { .panel h2 {
@@ -613,6 +675,11 @@ h3 {
background: var(--sky-soft); background: var(--sky-soft);
} }
[data-theme="dark"] .status-idea {
background: rgba(126, 143, 160, 0.24);
color: #ece8e4;
}
.status-soft { .status-soft {
background: var(--lilac-soft); background: var(--lilac-soft);
} }
@@ -701,6 +768,10 @@ h3 {
gap: 1rem; gap: 1rem;
} }
.dashboard-spaced-panel > .panel-head + * {
margin-top: 0.45rem;
}
.template-library-grid { .template-library-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -852,6 +923,38 @@ legend {
overflow: hidden; overflow: hidden;
} }
.day-tile.has-selection {
border-color: color-mix(in srgb, var(--accent) 34%, var(--line) 66%);
box-shadow: 0 20px 36px rgba(94, 68, 49, 0.16);
}
.day-tile.has-entries {
position: relative;
border-color: color-mix(in srgb, var(--accent) 24%, var(--line) 76%);
background:
linear-gradient(180deg, color-mix(in srgb, var(--surface) 90%, #ffe8d8 10%), color-mix(in srgb, var(--surface) 96%, #fff 4%));
box-shadow: 0 18px 34px rgba(94, 68, 49, 0.14);
}
.day-tile.has-entries::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: linear-gradient(180deg, color-mix(in srgb, var(--accent-strong) 76%, white 24%), color-mix(in srgb, var(--accent) 72%, transparent 28%));
opacity: 0.9;
}
.day-tile.has-entries .day-tile-summary {
background:
linear-gradient(180deg, rgba(255, 236, 221, 0.28), rgba(255, 255, 255, 0));
}
.day-tile.has-entries .status-pill {
background: color-mix(in srgb, var(--mint-soft) 78%, var(--surface) 22%);
border: 1px solid color-mix(in srgb, var(--mint-soft) 54%, var(--line) 46%);
}
.day-tile > summary::-webkit-details-marker { .day-tile > summary::-webkit-details-marker {
display: none; display: none;
} }
@@ -877,18 +980,84 @@ legend {
} }
.day-tile-icon { .day-tile-icon {
width: 2.8rem; width: 2.95rem;
height: 2.8rem; height: 2.95rem;
flex: 0 0 2.95rem;
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 1rem; border-radius: 0.9rem;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.92), var(--peach-soft)); background: linear-gradient(
color: var(--accent-strong); 180deg,
color-mix(in srgb, var(--surface-soft) 72%, #fff 28%),
color-mix(in srgb, var(--surface-soft) 92%, #f7e2cf 8%)
);
border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--line) 74%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 10px 24px rgba(223, 177, 134, 0.12);
color: #cf8450;
} }
.day-tile-icon .ui-icon { .day-tile-icon .ui-icon {
width: 1.15rem; width: 1.28rem;
height: 1.15rem; height: 1.28rem;
}
[data-theme="dark"] .day-tile-icon {
background: linear-gradient(180deg, rgba(86, 74, 69, 0.98), rgba(67, 58, 55, 0.98));
border: 1px solid rgba(243, 177, 125, 0.14);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
color: #f3bf90;
}
.day-tile.has-entries .day-tile-icon {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-soft) 82%, #fff 18%),
color-mix(in srgb, var(--accent-soft) 72%, #fff 28%)
);
border-color: color-mix(in srgb, var(--accent) 36%, var(--line) 64%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.84),
0 12px 26px rgba(94, 68, 49, 0.14);
}
.day-tile-summary-text {
margin: 0.2rem 0 0;
color: color-mix(in srgb, var(--text) 84%, white 16%);
font-size: 1.08rem;
}
.day-tile.has-entries .day-tile-summary-text {
color: color-mix(in srgb, var(--text) 90%, white 10%);
font-weight: 600;
}
[data-theme="dark"] .day-tile.has-entries {
border-color: color-mix(in srgb, var(--accent) 30%, var(--line) 70%);
background:
linear-gradient(180deg, color-mix(in srgb, var(--surface) 96%, #3f3430 4%), color-mix(in srgb, var(--surface) 100%, #000 0%));
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.26);
}
[data-theme="dark"] .day-tile.has-entries .day-tile-summary {
background:
linear-gradient(90deg, rgba(243, 177, 125, 0.10), rgba(243, 177, 125, 0.03) 38%, rgba(255, 255, 255, 0) 68%);
}
[data-theme="dark"] .day-tile.has-entries .day-tile-icon {
background: linear-gradient(180deg, rgba(97, 82, 76, 0.98), rgba(76, 65, 61, 0.98));
border-color: rgba(243, 177, 125, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .day-tile.has-entries .status-pill {
background: rgba(155, 198, 175, 0.20);
border-color: rgba(155, 198, 175, 0.16);
}
[data-theme="dark"] .day-tile.has-entries .day-tile-summary-text {
color: #f3ece7;
} }
.day-tile-body { .day-tile-body {
@@ -896,6 +1065,38 @@ legend {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.snack-reveal-panel {
padding: 1rem 1.1rem;
}
.snack-reveal-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.snack-reveal-button {
padding: 0.58rem 0.9rem;
}
.week-card-snack-actions {
display: grid;
gap: 0.7rem;
margin: 0.2rem 0 0.95rem;
padding: 0.8rem 0.9rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 94%, var(--surface-strong) 6%);
}
.week-card-snack-actions .eyebrow {
margin: 0;
}
.week-card-empty-copy {
margin-bottom: 0.95rem;
}
.quick-add-row { .quick-add-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -937,6 +1138,31 @@ legend {
background: color-mix(in srgb, var(--surface) 88%, #fff 12%); background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
} }
.planner-entry-edit {
margin-top: 0.85rem;
}
.planner-entry-edit > summary {
width: fit-content;
cursor: pointer;
list-style: none;
}
.planner-entry-edit > summary::-webkit-details-marker {
display: none;
}
.planner-entry-inline-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
margin-top: 0.8rem;
}
.planner-entry-inline-form .wide {
grid-column: 1 / -1;
}
.template-card, .template-card,
.template-list-card, .template-list-card,
.suggestion-card { .suggestion-card {
@@ -946,12 +1172,43 @@ legend {
background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%); background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%);
} }
[data-theme="dark"] .template-card,
[data-theme="dark"] .template-list-card,
[data-theme="dark"] .suggestion-card {
background: linear-gradient(180deg, rgba(66, 57, 54, 0.98), rgba(54, 47, 44, 0.99));
border-color: rgba(243, 177, 125, 0.12);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
}
.selected-quick-action {
background: linear-gradient(180deg, color-mix(in srgb, var(--accent-soft) 82%, #fff 18%), color-mix(in srgb, var(--surface-strong) 82%, #fff 18%));
border-color: color-mix(in srgb, var(--accent) 36%, var(--line) 64%);
box-shadow: 0 16px 30px rgba(94, 68, 49, 0.12);
}
.template-list-card, .template-list-card,
.week-template-row { .week-template-row {
display: grid; display: grid;
gap: 0.9rem; gap: 0.9rem;
} }
.template-list-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: center;
}
.template-list-card-actions form {
margin: 0;
}
.template-list-card .ghost-button,
.template-list-card .button {
width: auto;
align-self: flex-start;
}
.week-template-row { .week-template-row {
padding: 1rem; padding: 1rem;
border-radius: 18px; border-radius: 18px;
@@ -973,6 +1230,12 @@ legend {
border: 1px solid var(--line); border: 1px solid var(--line);
} }
[data-theme="dark"] .hint-chip {
background: linear-gradient(180deg, rgba(77, 68, 64, 0.96), rgba(63, 56, 53, 0.98));
border-color: rgba(243, 177, 125, 0.12);
color: #f0e8e2;
}
.suggestion-row { .suggestion-row {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -1105,24 +1368,99 @@ legend {
align-items: flex-start; align-items: flex-start;
} }
.week-card-count {
font-size: 1.25rem;
font-family: var(--font-heading);
margin: 0.8rem 0 0.2rem;
}
.week-card-actions { .week-card-actions {
margin-top: 1rem; margin-top: 1rem;
} }
.export-menu {
position: relative;
}
.export-menu > summary {
list-style: none;
}
.export-menu > summary::-webkit-details-marker {
display: none;
}
.export-menu-trigger::after {
content: "▾";
font-size: 0.8rem;
opacity: 0.7;
}
.export-menu[open] .export-menu-trigger {
background: var(--accent-soft);
}
.export-menu-panel {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
z-index: 14;
min-width: 13.5rem;
display: grid;
gap: 0.15rem;
padding: 0.45rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 96%, #fff 4%);
box-shadow: var(--shadow);
}
.export-menu-panel a {
display: block;
padding: 0.8rem 0.9rem;
border-radius: 14px;
color: var(--text);
text-decoration: none;
}
.export-menu-panel a:hover {
background: var(--accent-soft);
}
.week-card {
position: relative;
overflow: visible;
}
.week-card.has-open-picker {
z-index: 6;
}
.week-slot { .week-slot {
position: relative;
padding: 0.85rem; padding: 0.85rem;
border-radius: 18px; border-radius: 18px;
background: color-mix(in srgb, var(--surface-strong) 80%, #fff 20%); background: linear-gradient(180deg, color-mix(in srgb, var(--surface-strong) 84%, #fff 16%), color-mix(in srgb, var(--surface) 90%, #fff 10%));
border: 1px solid var(--line); border: 1px solid var(--line);
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
} }
.week-slot.has-entries {
border-color: color-mix(in srgb, var(--accent) 24%, var(--line) 76%);
background:
linear-gradient(180deg, color-mix(in srgb, var(--surface) 90%, #ffe8d8 10%), color-mix(in srgb, var(--surface) 96%, #fff 4%));
box-shadow: 0 18px 34px rgba(94, 68, 49, 0.12);
}
.week-slot.has-entries::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
border-radius: 18px 0 0 18px;
background: linear-gradient(180deg, color-mix(in srgb, var(--accent-strong) 76%, white 24%), color-mix(in srgb, var(--accent) 72%, transparent 28%));
opacity: 0.9;
}
.week-slot.week-slot-snack.has-entries {
background:
linear-gradient(180deg, color-mix(in srgb, var(--surface) 92%, #ffe3cf 8%), color-mix(in srgb, var(--surface) 98%, #fff 2%));
}
.week-slot.is-drag-over { .week-slot.is-drag-over {
background: var(--accent-soft); background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 60%, var(--line) 40%); border-color: color-mix(in srgb, var(--accent) 60%, var(--line) 40%);
@@ -1137,11 +1475,105 @@ legend {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.week-slot-title {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.week-slot-title .ui-icon {
width: 1rem;
height: 1rem;
color: var(--accent-strong);
flex: 0 0 auto;
}
.week-slot-head-meta {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.week-slot-count {
min-width: 1.9rem;
text-align: center;
font-weight: 700;
color: var(--muted);
}
.week-slot.has-entries .week-slot-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 2rem;
padding: 0 0.55rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--mint-soft) 54%, var(--line) 46%);
background: color-mix(in srgb, var(--mint-soft) 78%, var(--surface) 22%);
color: color-mix(in srgb, var(--text) 86%, #173127 14%);
}
.week-slot-add {
width: 1.9rem;
height: 1.9rem;
display: inline-grid;
place-items: center;
padding: 0;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--accent-soft);
color: var(--text);
font-weight: 700;
font-size: 1.15rem;
line-height: 1;
text-align: center;
}
.week-slot-add:hover {
background: color-mix(in srgb, var(--accent-soft) 72%, #fff 28%);
}
.week-slot-picker {
position: absolute;
top: calc(100% + 0.55rem);
left: 0;
right: 0;
z-index: 12;
display: grid;
gap: 0.9rem;
padding: 0.95rem;
border-radius: 18px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 96%, #fff 4%);
box-shadow: var(--shadow);
}
.week-slot-picker[hidden] {
display: none;
}
.week-slot-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.week-slot-picker-close {
padding: 0.5rem 0.85rem;
}
.week-slot-picker-search {
margin-bottom: 0.1rem;
}
.plan-chip { .plan-chip {
padding: 0.7rem 0.8rem; padding: 0.7rem 0.8rem;
border-radius: 16px; border-radius: 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 246, 239, 0.92)); background: linear-gradient(180deg, color-mix(in srgb, var(--surface) 72%, #fff 28%), color-mix(in srgb, var(--accent-soft) 55%, var(--surface) 45%));
border: 1px solid var(--line); border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--line) 82%);
cursor: grab; cursor: grab;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
} }
@@ -1151,6 +1583,17 @@ legend {
opacity: 0.8; opacity: 0.8;
} }
.plan-chip.is-editable {
cursor: pointer;
}
.plan-chip.is-editable:hover {
border-color: color-mix(in srgb, var(--accent) 34%, var(--line) 66%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
0 10px 22px rgba(94, 68, 49, 0.12);
}
.plan-chip:active { .plan-chip:active {
cursor: grabbing; cursor: grabbing;
} }
@@ -1160,11 +1603,177 @@ legend {
transform: scale(0.98); transform: scale(0.98);
} }
.week-slot-actions {
display: flex;
justify-content: flex-end;
margin-top: 0.65rem;
}
.week-slot-copy {
padding: 0.55rem 0.85rem;
}
.week-entry-dialog {
padding: 0;
border: 0;
background: transparent;
max-width: min(34rem, calc(100vw - 2rem));
width: min(34rem, calc(100vw - 2rem));
}
.week-entry-dialog::backdrop {
background: rgba(29, 22, 19, 0.54);
backdrop-filter: blur(6px);
}
.week-entry-dialog-card {
display: grid;
gap: 1rem;
padding: 1.1rem;
border-radius: 22px;
border: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 98%, #fff 2%);
box-shadow: var(--shadow);
}
.week-entry-dialog-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
}
.week-entry-dialog-head h3 {
margin: 0 0 0.2rem;
font-size: 1.25rem;
}
.week-entry-dialog-head p {
margin: 0;
color: var(--muted);
}
.week-entry-dialog-actions {
display: flex;
justify-content: flex-start;
}
.week-entry-remove-form {
margin: 0;
}
.plan-chip small, .plan-chip small,
.week-slot-empty { .week-slot-empty {
color: var(--muted); color: var(--muted);
} }
.week-slot-empty {
display: grid;
justify-items: start;
gap: 0.65rem;
padding: 0.85rem;
border-radius: 16px;
border: 1px dashed color-mix(in srgb, var(--line) 74%, var(--accent) 26%);
background: color-mix(in srgb, var(--surface) 92%, #fff 8%);
}
.week-slot-empty p {
margin: 0;
}
[data-theme="dark"] .week-slot {
background: linear-gradient(180deg, rgba(66, 57, 54, 0.96), rgba(58, 50, 48, 0.98));
border-color: rgba(243, 177, 125, 0.14);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
[data-theme="dark"] .week-card-snack-actions {
background: rgba(47, 40, 38, 0.72);
border-color: rgba(243, 177, 125, 0.10);
}
[data-theme="dark"] .export-menu-panel {
background: rgba(43, 37, 35, 0.98);
border-color: rgba(243, 177, 125, 0.14);
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.34);
}
[data-theme="dark"] .export-menu-panel a:hover {
background: rgba(243, 177, 125, 0.10);
}
[data-theme="dark"] .week-slot.has-entries {
border-color: rgba(243, 177, 125, 0.18);
background:
linear-gradient(180deg, rgba(70, 60, 57, 0.98), rgba(58, 50, 48, 0.99));
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.24);
}
[data-theme="dark"] .week-slot.week-slot-snack.has-entries {
background:
linear-gradient(180deg, rgba(75, 64, 60, 0.98), rgba(60, 52, 49, 0.99));
}
[data-theme="dark"] .week-slot.has-entries .week-slot-count {
border-color: rgba(155, 198, 175, 0.16);
background: rgba(155, 198, 175, 0.20);
color: #eef8f2;
}
[data-theme="dark"] .week-slot.is-drag-over {
background: linear-gradient(180deg, rgba(87, 71, 64, 0.98), rgba(72, 58, 53, 0.98));
border-color: rgba(243, 177, 125, 0.24);
}
[data-theme="dark"] .week-slot-add {
background: rgba(243, 177, 125, 0.16);
border-color: rgba(243, 177, 125, 0.18);
color: #f7efe9;
}
[data-theme="dark"] .week-slot-add:hover {
background: rgba(243, 177, 125, 0.22);
}
[data-theme="dark"] .week-slot-picker {
background: rgba(43, 37, 35, 0.98);
border-color: rgba(243, 177, 125, 0.14);
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.34);
}
[data-theme="dark"] .plan-chip {
background: linear-gradient(180deg, rgba(86, 72, 66, 0.98), rgba(72, 60, 56, 0.98));
border-color: rgba(243, 177, 125, 0.18);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
[data-theme="dark"] .plan-chip.is-editable:hover {
border-color: rgba(243, 177, 125, 0.3);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 12px 26px rgba(0, 0, 0, 0.24);
}
[data-theme="dark"] .week-slot-copy {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(243, 177, 125, 0.12);
}
[data-theme="dark"] .week-slot-copy:hover {
background: rgba(243, 177, 125, 0.10);
}
[data-theme="dark"] .week-slot-empty {
background: rgba(58, 50, 48, 0.72);
border-color: rgba(243, 177, 125, 0.16);
}
[data-theme="dark"] .week-entry-dialog-card {
background: rgba(43, 37, 35, 0.98);
border-color: rgba(243, 177, 125, 0.14);
box-shadow: 0 24px 50px rgba(0, 0, 0, 0.34);
}
.flash-stack { .flash-stack {
display: grid; display: grid;
gap: 0.7rem; gap: 0.7rem;
@@ -1193,6 +1802,11 @@ legend {
min-width: 5rem; min-width: 5rem;
} }
.theme-toggle,
.mobile-extra-button[data-theme-toggle] {
gap: 0.55rem;
}
.ui-icon { .ui-icon {
width: 1rem; width: 1rem;
height: 1rem; height: 1rem;
@@ -1237,6 +1851,36 @@ legend {
mask-image: url("../icons/fa/calendar-days.svg"); mask-image: url("../icons/fa/calendar-days.svg");
} }
.icon-daypart-breakfast {
-webkit-mask-image: url("../icons/dayparts/breakfast.svg");
mask-image: url("../icons/dayparts/breakfast.svg");
}
.icon-daypart-morning-snack {
-webkit-mask-image: url("../icons/dayparts/morning-snack.svg");
mask-image: url("../icons/dayparts/morning-snack.svg");
}
.icon-daypart-lunch {
-webkit-mask-image: url("../icons/dayparts/lunch.svg");
mask-image: url("../icons/dayparts/lunch.svg");
}
.icon-daypart-afternoon-snack {
-webkit-mask-image: url("../icons/dayparts/afternoon-snack.svg");
mask-image: url("../icons/dayparts/afternoon-snack.svg");
}
.icon-daypart-dinner {
-webkit-mask-image: url("../icons/dayparts/dinner.svg");
mask-image: url("../icons/dayparts/dinner.svg");
}
.icon-daypart-late-snack {
-webkit-mask-image: url("../icons/dayparts/late-snack.svg");
mask-image: url("../icons/dayparts/late-snack.svg");
}
.icon-archive { .icon-archive {
-webkit-mask-image: url("../icons/fa/archive.svg"); -webkit-mask-image: url("../icons/fa/archive.svg");
mask-image: url("../icons/fa/archive.svg"); mask-image: url("../icons/fa/archive.svg");
@@ -1282,6 +1926,16 @@ legend {
mask-image: url("../icons/fa/mobile-screen-button.svg"); mask-image: url("../icons/fa/mobile-screen-button.svg");
} }
.icon-sun-theme {
-webkit-mask-image: url("../icons/fa/theme-sun.svg");
mask-image: url("../icons/fa/theme-sun.svg");
}
.icon-moon-theme {
-webkit-mask-image: url("../icons/fa/theme-moon.svg");
mask-image: url("../icons/fa/theme-moon.svg");
}
.icon-apple-whole { .icon-apple-whole {
-webkit-mask-image: url("../icons/fa/apple-whole.svg"); -webkit-mask-image: url("../icons/fa/apple-whole.svg");
mask-image: url("../icons/fa/apple-whole.svg"); mask-image: url("../icons/fa/apple-whole.svg");
@@ -1433,6 +2087,18 @@ legend {
display: none; display: none;
} }
.day-tile-icon {
width: 2.8rem;
height: 2.8rem;
flex-basis: 2.8rem;
border-radius: 0.85rem;
}
.day-tile-icon .ui-icon {
width: 1.12rem;
height: 1.12rem;
}
.hero, .hero,
.page-intro, .page-intro,
.panel, .panel,
@@ -1505,6 +2171,10 @@ legend {
min-width: 100%; min-width: 100%;
} }
.planner-entry-inline-form {
grid-template-columns: 1fr;
}
.mobile-nav-stack { .mobile-nav-stack {
position: fixed; position: fixed;
left: 0.75rem; left: 0.75rem;
@@ -1587,6 +2257,10 @@ legend {
.mobile-nav-button { .mobile-nav-button {
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
background: transparent;
color: var(--muted);
border: 0;
box-shadow: none;
} }
.mobile-bottom-nav a.active, .mobile-bottom-nav a.active,
@@ -1594,6 +2268,62 @@ legend {
.mobile-nav-button.is-open { .mobile-nav-button.is-open {
background: var(--accent-soft); background: var(--accent-soft);
color: var(--text); color: var(--text);
box-shadow: none;
}
[data-theme="dark"] .mobile-nav-button.is-open {
background: var(--accent-soft);
color: var(--text);
border: 0;
box-shadow: none;
}
[data-theme="dark"] .mobile-nav-stack button.mobile-nav-button:not(.secondary):not(.ghost-button) {
background: transparent;
color: var(--muted);
border: 0;
box-shadow: none;
}
[data-theme="dark"] .mobile-nav-stack button.mobile-nav-button:not(.secondary):not(.ghost-button):hover {
background: transparent;
color: var(--muted);
border: 0;
box-shadow: none;
transform: none;
}
[data-theme="dark"] .mobile-nav-stack button.mobile-nav-button.is-open:not(.secondary):not(.ghost-button),
[data-theme="dark"] .mobile-nav-stack button.mobile-nav-button.is-open:not(.secondary):not(.ghost-button):hover {
background: var(--accent-soft);
color: var(--text);
border: 0;
box-shadow: none;
transform: none;
}
[data-theme="dark"] .mobile-nav-extension .mobile-extra-button,
[data-theme="dark"] .mobile-nav-extension .mobile-extra-form .mobile-extra-button {
background: transparent;
color: var(--muted);
border: 0;
box-shadow: none;
}
[data-theme="dark"] .mobile-nav-extension .mobile-extra-button[data-theme-toggle] {
background: transparent;
color: var(--muted);
border: 0;
box-shadow: none;
}
[data-theme="dark"] .mobile-nav-extension .mobile-extra-button:hover,
[data-theme="dark"] .mobile-nav-extension .mobile-extra-button:focus-visible,
[data-theme="dark"] .mobile-nav-extension .mobile-extra-form .mobile-extra-button:hover,
[data-theme="dark"] .mobile-nav-extension .mobile-extra-form .mobile-extra-button:focus-visible {
background: var(--accent-soft);
color: var(--text);
box-shadow: none;
} }
.mobile-profile-link { .mobile-profile-link {
@@ -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="M362.2 37L213.9 16 81.7 86.7 16 222.1 42 370.4 149.8 475 298.1 496 430.3 425.3 496 289.9 470 141.6 362.2 37zM208 144a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM144 336a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm224-64a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>

After

Width:  |  Height:  |  Size: 505 B

@@ -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="M243.2 .3c4.2-.2 8.5-.3 12.8-.3 62.1 0 118.9 22.1 163.3 58.8L314.6 163.4 243.2 .3zM194 7.6L307.4 266.6 267.3 306.8 12 178.3C38.8 94.2 107.7 29 194 7.6zM1.6 226.8l166 83.6-108.9 108.9C22.1 374.9 0 318.1 0 256 0 246.1 .6 236.4 1.6 226.8zM92.7 453.2l120.1-120.1 11.2 5.6 0 171.3c-49.5-6.2-94.7-26.5-131.3-56.8zM341.2 224l-5.9-13.4 117.9-117.9c30.3 36.6 50.6 81.7 56.8 131.3l-168.8 0z"/></svg>

After

Width:  |  Height:  |  Size: 648 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 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="M56 16L40 16 0 152c0 41.5 31.6 75.6 72 79.6l0 264.4 48 0 0-264.4c40.4-4 72-38.1 72-79.6l-40-136-16 0 0 136-16 0-16-136-16 0-16 136-16 0 0-136zm584 0S512 32 512 160l0 160 80 0 0 176 48 0 0-480zM336 32c-43.8 0-84.7 12.6-119.2 34.3l19.1 64.9c27.4-22 62.2-35.2 100.1-35.2 52.3 0 98.8 25.1 128 64 0-29.7 5.5-55.2 14.5-76.9-38.7-31.9-88.3-51.1-142.5-51.1zm0 384c-86.1 0-156.3-68-159.9-153.2-2.7 1.5-5.4 3-8.1 4.3l0 137c41 46.5 101.1 75.8 168 75.8 82.9 0 155.3-45 194-112l-66 0 0-16c-29.2 38.9-75.7 64-128 64zM448 256a112 112 0 1 0 -224 0 112 112 0 1 0 224 0z"/></svg>

After

Width:  |  Height:  |  Size: 820 B

@@ -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="M359.7 21.9C272.6 43.5 208 122.2 208 216 208 326.4 297.5 416 408 416 426.5 416 444.4 413.5 461.4 408.8 414.8 471.4 340.1 512 256 512 114.6 512 0 397.4 0 256S114.6 0 256 0c36.9 0 72 7.8 103.7 21.9z"/></svg>

After

Width:  |  Height:  |  Size: 464 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="M0 64l512 0 0 112-512 0 0-112zM320 384l96-48 96 0 0 112-512 0 0-112 224 0 96 48zM144.2 209.1l111.8 29.8 111.8-29.8 8-2.1 8 2c74.8 18.7 117.2 29.3 127 31.8l-15.5 62.1c-11.2-2.8-50.9-12.7-119-29.8l-112 29.9-8.2 2.2-8.2-2.2-112-29.9c-68.1 17-107.8 27-119 29.8L1.2 240.7c9.9-2.5 52.2-13.1 127-31.8l8-2 8 2.1z"/></svg>

After

Width:  |  Height:  |  Size: 572 B

@@ -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="M208 96l0-16c0-44.2 35.8-80 80-80l32 0 0 32c0 44.2-35.8 80-80 80l-32 0 0-16zM0 288c0-76.3 35.7-160 112-160l112 32 112-32c76.3 0 112 83.7 112 160 0 128-80 224-160 224l-64-16-64 16C80 512 0 416 0 288z"/></svg>

After

Width:  |  Height:  |  Size: 466 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><path fill="currentColor" d="M99.1 99.8c36.8-31.2 88.7-50.7 158.9-51.8 70.8 1.3 118.7 27.7 149.8 67.9 32.6 42.2 48.7 102.3 48.7 172s-16.1 129.8-48.7 172c-31 40.2-79 66.6-149.8 67.9-70.2-1.1-122.1-20.6-158.9-51.8 14.2 2.5 29.3 3.8 45.4 3.8 4.1 0 8.1-.1 12-.2l0 .2c80.4 0 138.3-19.7 176.2-55.9 38-36.3 51.8-85.6 51.8-136.1s-13.8-99.8-51.8-136.1C294.8 115.7 236.9 96 156.5 96l0 .2c-3.9-.2-7.9-.2-12-.2-16.1 0-31.2 1.3-45.4 3.8zM252.5 .2C125.1 3.6 42.8 62.2 3.5 150.3l38.2 27.5c21.9-20.1 54.9-33.8 102.8-33.8 53.4 0 88.4 16.9 110.2 41.2 22.3 24.8 33.8 60.5 33.8 102.8S277 366 254.7 390.8c-21.9 24.4-56.8 41.2-110.2 41.2-47.9 0-80.9-13.6-102.8-33.8L3.5 425.7c39.3 88.2 121.6 146.7 249 150.1l0 .2c1.9 0 3.8 0 5.6 0 2.1 0 4.2 0 6.4 0 97.8 0 170.8-31.5 219.2-85.3 47.9-53.4 68.8-125.7 68.8-202.7S531.6 138.7 483.6 85.3c-48.3-53.8-121.4-85.3-219.2-85.3-2.1 0-4.3 0-6.4 0-1.9 0-3.7 0-5.6 0l0 .2zM216.5 252c0-26.5-14.4-48-48-48s-48 21.5-48 48 14.4 48 48 48 48-21.5 48-48zm-144 96c25.2 0 36-16.1 36-36s-10.8-36-36-36-36 16.1-36 36 10.8 36 36 36z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><path fill="currentColor" d="M309.6 0L266.4 0 222 47.9c-13.9 15-33.5 23.3-53.9 23L89.2 63 63 89.4 70.9 164.2c.3 19.5-7.3 38.3-21.1 52.1L0 266.1 0 309.6 47.9 354c15 13.9 23.3 33.5 23 53.9l-7.9 78.8 28 26 73.3-12.8c23.9-2.2 47.3 7.7 62.4 26.4l40.2 49.6 42.3 0 40.2-49.6c15.1-18.7 38.5-28.5 62.4-26.4l73.3 12.8 28-26-7.9-78.8c-.3-20.4 8-40 23-53.9l47.9-44.4 0-43.5-49.8-49.8c-13.8-13.8-21.4-32.6-21.1-52.1l7.9-74.8-26.3-26.4-78.8 7.9c-20.4 .3-40-8-53.9-23L309.6 0zM288 64.4l4.8 9.4c26.8 52.1 87.4 77.2 143.2 59.3l10-3.2-3.2 10c-17.9 55.8 7.2 116.4 59.3 143.2l9.4 4.8-9.4 4.8c-52.1 26.8-77.2 87.4-59.3 143.2l3.2 10-10-3.2c-55.8-17.9-116.4 7.2-143.2 59.3l-4.8 9.4-4.8-9.4c-26.8-52.1-87.4-77.2-143.2-59.3l-10 3.2 3.2-10c17.9-55.8-7.2-116.4-59.3-143.2l-9.4-4.8 9.4-4.8c52.1-26.8 77.2-87.4 59.3-143.2l-3.2-10 10 3.2c55.8 17.9 116.4-7.2 143.2-59.3l4.8-9.4zM322.3 224c8.6 14.4 13.7 36.5 13.7 64s-5.1 49.6-13.7 64c-7.8 13.1-18.4 20-34.3 20s-26.5-6.9-34.3-20c-8.6-14.4-13.7-36.5-13.7-64s5.1-49.6 13.7-64c7.8-13.1 18.4-20 34.3-20s26.5 6.9 34.3 20zM288 156c-43.2 0-77.2 14-100.2 39.6-22.6 25.1-31.8 58.5-31.8 92.4s9.2 67.3 31.8 92.4c23 25.6 57 39.6 100.2 39.6s77.2-14 100.2-39.6C410.8 355.3 420 321.9 420 288s-9.2-67.3-31.8-92.4C365.2 170 331.2 156 288 156z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+279
View File
@@ -4,6 +4,38 @@
return meta ? meta.getAttribute("content") : ""; return meta ? meta.getAttribute("content") : "";
}; };
const scrollKey = "nouri-week-scroll";
const rememberScroll = () => {
sessionStorage.setItem(scrollKey, String(window.scrollY));
};
const restoreScroll = () => {
const savedScroll = sessionStorage.getItem(scrollKey);
if (!savedScroll) return;
sessionStorage.removeItem(scrollKey);
window.requestAnimationFrame(() => {
window.scrollTo({ top: Number(savedScroll), left: 0, behavior: "auto" });
});
};
const postAndRefreshInPlace = async (form) => {
const payload = new URLSearchParams(new FormData(form));
const response = await fetch(form.action, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
},
body: payload.toString(),
});
if (!response.ok) {
throw new Error("request failed");
}
rememberScroll();
window.location.reload();
};
const initWeekDragAndDrop = () => { const initWeekDragAndDrop = () => {
const board = document.querySelector(".week-board"); const board = document.querySelector(".week-board");
if (!board) return; if (!board) return;
@@ -63,11 +95,26 @@
} }
const result = await response.json(); const result = await response.json();
rememberScroll();
if (result.redirect_url) { if (result.redirect_url) {
window.location.href = result.redirect_url; window.location.href = result.redirect_url;
} else { } else {
window.location.reload(); window.location.reload();
} }
} catch (_error) {
rememberScroll();
window.location.reload();
}
});
});
};
const initWeekCopyForward = () => {
document.querySelectorAll(".js-copy-forward-form").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postAndRefreshInPlace(form);
} catch (_error) { } catch (_error) {
window.location.reload(); window.location.reload();
} }
@@ -75,7 +122,239 @@
}); });
}; };
const initWeekSlotPicker = () => {
const board = document.querySelector(".week-board");
if (!board) return;
const closeAllPickers = () => {
board.querySelectorAll(".week-card").forEach((card) => {
card.classList.remove("has-open-picker");
});
board.querySelectorAll(".week-slot").forEach((slot) => {
slot.classList.remove("is-picker-open");
});
board.querySelectorAll(".week-slot-picker").forEach((picker) => {
picker.hidden = true;
});
};
board.querySelectorAll("[data-week-slot-picker-open]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
const slot = button.closest(".week-slot");
if (!slot) return;
const picker = slot.querySelector(".week-slot-picker");
if (!picker) return;
const card = slot.closest(".week-card");
const shouldOpen = picker.hidden;
closeAllPickers();
if (shouldOpen) {
picker.hidden = false;
slot.classList.add("is-picker-open");
if (card) {
card.classList.add("has-open-picker");
}
const filterInput = picker.querySelector("[data-filter-input]");
if (filterInput instanceof HTMLInputElement) {
filterInput.value = "";
filterInput.dispatchEvent(new Event("input", { bubbles: true }));
window.requestAnimationFrame(() => filterInput.focus());
}
}
});
});
board.querySelectorAll("[data-week-slot-picker-close]").forEach((button) => {
button.addEventListener("click", () => {
const slot = button.closest(".week-slot");
if (!slot) return;
const picker = slot.querySelector(".week-slot-picker");
const card = slot.closest(".week-card");
if (!picker) return;
picker.hidden = true;
slot.classList.remove("is-picker-open");
if (card) {
card.classList.remove("has-open-picker");
}
});
});
board.querySelectorAll(".js-week-slot-submit").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postAndRefreshInPlace(form);
} catch (_error) {
window.location.reload();
}
});
});
document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest(".week-slot")) return;
closeAllPickers();
});
};
const initWeekEntryDialogs = () => {
const board = document.querySelector(".week-board");
if (!board) return;
const openDialog = (trigger) => {
const dialogId = trigger.getAttribute("data-week-entry-dialog-id");
if (!dialogId) return;
const dialog = document.getElementById(dialogId);
if (!(dialog instanceof HTMLDialogElement)) return;
if (!dialog.open) {
dialog.showModal();
}
};
board.querySelectorAll("[data-week-entry-open]").forEach((entry) => {
entry.addEventListener("click", (event) => {
if (event.target instanceof Element && event.target.closest("button, a, input, select, textarea, label, form")) {
return;
}
openDialog(entry);
});
entry.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
openDialog(entry);
});
});
document.querySelectorAll(".week-entry-dialog").forEach((dialog) => {
if (!(dialog instanceof HTMLDialogElement)) return;
dialog.addEventListener("click", (event) => {
const rect = dialog.getBoundingClientRect();
const clickedInside =
rect.top <= event.clientY &&
event.clientY <= rect.top + rect.height &&
rect.left <= event.clientX &&
event.clientX <= rect.left + rect.width;
if (!clickedInside) {
dialog.close();
}
});
});
document.querySelectorAll("[data-week-entry-close]").forEach((button) => {
button.addEventListener("click", () => {
const dialog = button.closest(".week-entry-dialog");
if (dialog instanceof HTMLDialogElement) {
dialog.close();
}
});
});
document.querySelectorAll(".js-week-entry-submit").forEach((form) => {
form.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await postAndRefreshInPlace(form);
} catch (_error) {
window.location.reload();
}
});
});
};
const syncActionContainerVisibility = (container) => {
if (!(container instanceof HTMLElement)) return;
const hasVisibleButtons = Array.from(container.querySelectorAll("button")).some((button) => {
return !button.hidden;
});
container.hidden = !hasVisibleButtons;
};
const revealActionButton = (container, selector) => {
if (!(container instanceof HTMLElement) || !selector) return;
const button = container.querySelector(`button[data-target="${selector}"]`);
if (!(button instanceof HTMLButtonElement)) return;
button.hidden = false;
container.hidden = false;
};
const initDaySnackReveal = () => {
document.querySelectorAll("[data-day-snack-open]").forEach((button) => {
button.addEventListener("click", () => {
const selector = button.getAttribute("data-target");
if (!selector) return;
const tile = document.querySelector(selector);
if (!(tile instanceof HTMLDetailsElement)) return;
tile.hidden = false;
tile.open = true;
button.hidden = true;
tile.scrollIntoView({ block: "nearest", inline: "nearest" });
syncActionContainerVisibility(button.closest("[data-day-snack-actions]"));
});
});
document.querySelectorAll("[data-day-snack-hide]").forEach((button) => {
button.addEventListener("click", () => {
const selector = button.getAttribute("data-target");
if (!selector) return;
const tile = document.querySelector(selector);
if (!(tile instanceof HTMLDetailsElement)) return;
tile.open = false;
tile.hidden = true;
revealActionButton(document.querySelector("[data-day-snack-actions]"), selector);
});
});
};
const initWeekSnackReveal = () => {
document.querySelectorAll("[data-week-snack-slot-open]").forEach((button) => {
button.addEventListener("click", () => {
const selector = button.getAttribute("data-target");
if (!selector) return;
const slot = document.querySelector(selector);
if (!(slot instanceof HTMLElement)) return;
slot.hidden = false;
button.hidden = true;
syncActionContainerVisibility(button.closest("[data-week-snack-actions]"));
const openButton = slot.querySelector("[data-week-slot-picker-open]");
if (openButton instanceof HTMLButtonElement) {
openButton.click();
} else {
slot.scrollIntoView({ block: "nearest", inline: "nearest" });
}
});
});
document.querySelectorAll("[data-week-snack-slot-hide]").forEach((button) => {
button.addEventListener("click", () => {
const selector = button.getAttribute("data-target");
if (!selector) return;
const slot = document.querySelector(selector);
if (!(slot instanceof HTMLElement)) return;
const picker = slot.querySelector(".week-slot-picker");
if (picker instanceof HTMLElement) {
picker.hidden = true;
}
slot.classList.remove("is-picker-open");
slot.hidden = true;
const card = slot.closest(".week-card");
if (card) {
card.classList.remove("has-open-picker");
}
revealActionButton(slot.closest(".week-card")?.querySelector("[data-week-snack-actions]"), selector);
});
});
};
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
restoreScroll();
initWeekDragAndDrop(); initWeekDragAndDrop();
initWeekCopyForward();
initWeekSlotPicker();
initWeekEntryDialogs();
initDaySnackReveal();
initWeekSnackReveal();
}); });
})(); })();
+16 -1
View File
@@ -12,7 +12,22 @@
root.dataset.theme = finalTheme; root.dataset.theme = finalTheme;
toggles().forEach((button) => { toggles().forEach((button) => {
button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel"; const nextModeLabel = finalTheme === "dark" ? "Hell" : "Dunkel";
const label = button.querySelector("[data-theme-label]");
const icon = button.querySelector("[data-theme-icon]");
if (label) {
label.textContent = nextModeLabel;
} else {
button.textContent = nextModeLabel;
}
if (icon) {
icon.classList.toggle("icon-sun-theme", finalTheme === "dark");
icon.classList.toggle("icon-moon-theme", finalTheme !== "dark");
}
button.setAttribute("aria-label", `${nextModeLabel} aktivieren`);
}); });
}; };
+47 -2
View File
@@ -78,8 +78,8 @@
const applyFilter = () => { const applyFilter = () => {
const term = input.value.trim().toLowerCase(); const term = input.value.trim().toLowerCase();
if (!term) { if (!term) {
items.forEach((item) => { items.forEach((item, index) => {
item.hidden = false; item.hidden = hasLimit ? index >= resultLimit : false;
}); });
syncGroups(); syncGroups();
return; return;
@@ -109,8 +109,53 @@
}); });
}; };
const initIosPullToRefresh = () => {
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
if (!isAppleTouchDevice) return;
let startY = 0;
let maxPull = 0;
let tracking = false;
window.addEventListener("touchstart", (event) => {
if (window.scrollY > 0) {
tracking = false;
return;
}
startY = event.touches[0].clientY;
maxPull = 0;
tracking = true;
}, { passive: true });
window.addEventListener("touchmove", (event) => {
if (!tracking) return;
const currentY = event.touches[0].clientY;
maxPull = Math.max(maxPull, currentY - startY);
}, { passive: true });
window.addEventListener("touchend", () => {
if (tracking && maxPull > 96 && window.scrollY <= 2) {
window.location.reload();
}
tracking = false;
maxPull = 0;
}, { passive: true });
document.addEventListener("gesturestart", (event) => {
event.preventDefault();
});
document.addEventListener("touchmove", (event) => {
if (event.touches.length > 1) {
event.preventDefault();
}
}, { passive: false });
};
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
initMobileSheet(); initMobileSheet();
initFilterInputs(); initFilterInputs();
initIosPullToRefresh();
}); });
})(); })();
+6
View File
@@ -66,6 +66,12 @@
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %} {% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
</button> </button>
</form> </form>
{% if category.name not in default_categories %}
<form method="post" action="{{ url_for('admin.category_delete', category_id=category.id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Löschen</button>
</form>
{% endif %}
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
+9 -6
View File
@@ -2,7 +2,7 @@
<html lang="de" data-theme="auto"> <html lang="de" data-theme="auto">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>{% block title %}Nouri{% endblock %}</title> <title>{% block title %}Nouri{% endblock %}</title>
<meta name="theme-color" content="#de9862"> <meta name="theme-color" content="#de9862">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -53,7 +53,10 @@
{% if g.user %} {% if g.user %}
<div class="desktop-header-sub"> <div class="desktop-header-sub">
<div class="header-actions desktop-actions"> <div class="header-actions desktop-actions">
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Hell</button> <button class="theme-toggle ghost-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
<span class="ui-icon icon-sun-theme" data-theme-icon></span>
<span data-theme-label>Hell</span>
</button>
<a class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a> <a class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a>
<a class="user-chip" href="{{ url_for('auth.profile') }}"> <a class="user-chip" href="{{ url_for('auth.profile') }}">
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span> <span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
@@ -91,7 +94,7 @@
<footer class="site-footer"> <footer class="site-footer">
<div class="footer-copy"> <div class="footer-copy">
<span>Version {{ app_version }}</span> <a href="{{ app_release_url }}" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span> <span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
</div> </div>
<div class="footer-copy"> <div class="footer-copy">
@@ -114,9 +117,9 @@
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a> <a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
<a class="mobile-extra-link" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a> <a class="mobile-extra-link" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
{% endif %} {% endif %}
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle> <button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
<span class="ui-icon icon-mobile-screen-button"></span> <span class="ui-icon icon-sun-theme" data-theme-icon></span>
<span>Modus</span> <span data-theme-label>Hell</span>
</button> </button>
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form"> <form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
{{ csrf_input() }} {{ csrf_input() }}
+20 -2
View File
@@ -91,7 +91,7 @@
{% endif %} {% endif %}
</article> </article>
<article class="panel"> <article class="panel dashboard-spaced-panel">
<div class="panel-head"> <div class="panel-head">
<h2>Kurz griffbereit</h2> <h2>Kurz griffbereit</h2>
<a href="{{ url_for('main.home_view') }}">Alles unter Zuhause</a> <a href="{{ url_for('main.home_view') }}">Alles unter Zuhause</a>
@@ -122,7 +122,7 @@
</section> </section>
<section class="two-column"> <section class="two-column">
<article class="panel"> <article class="panel dashboard-spaced-panel">
<div class="panel-head"> <div class="panel-head">
<h2>Was zuhause gut zusammenpasst</h2> <h2>Was zuhause gut zusammenpasst</h2>
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a> <a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
@@ -134,8 +134,26 @@
<div> <div>
<strong>{{ suggestion.title }}</strong> <strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small> <small>{{ suggestion.reason }}</small>
{% if suggestion.needs_shopping and suggestion.missing_components %}
<div class="chip-row">
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
</div> </div>
{% endif %}
</div>
<div class="template-list-card-actions">
{% if suggestion.existing_item_id %}
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
{% else %}
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a> <a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
{{ csrf_input() }}
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
</form>
{% endif %}
</div>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+18
View File
@@ -51,8 +51,26 @@
<div> <div>
<strong>{{ suggestion.title }}</strong> <strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small> <small>{{ suggestion.reason }}</small>
{% if suggestion.needs_shopping and suggestion.missing_components %}
<div class="chip-row">
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
</div> </div>
{% endif %}
</div>
<div class="template-list-card-actions">
{% if suggestion.existing_item_id %}
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
{% else %}
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a> <a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
{{ csrf_input() }}
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
</form>
{% endif %}
</div>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+85 -7
View File
@@ -54,26 +54,53 @@
</section> </section>
<section class="planner-day-stack"> <section class="planner-day-stack">
{% set hidden_snack_sections = sections | selectattr('is_snack_daypart') | rejectattr('visible_by_default') | list %}
{% if hidden_snack_sections %}
<section class="panel compact-form-panel snack-reveal-panel" data-day-snack-actions>
<div class="panel-head">
<h2>Zwischenmahlzeit hinzufügen</h2>
</div>
<div class="chip-row snack-reveal-actions">
{% for section in hidden_snack_sections %}
<button
class="ghost-button snack-reveal-button"
type="button"
data-day-snack-open
data-target="#daypart-{{ section.daypart.id }}"
>
{{ section.daypart.name }}
</button>
{% endfor %}
</div>
</section>
{% endif %}
{% for section in sections %} {% for section in sections %}
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}> <details
class="day-tile{% if section.entries %} has-entries{% endif %}{% if section.selected_quick_action %} has-selection{% endif %}"
id="daypart-{{ section.daypart.id }}"
{% if section.is_snack_daypart and not section.visible_by_default %}hidden data-day-snack-tile{% endif %}
{% if section.is_open %}open{% endif %}
>
<summary class="day-tile-summary"> <summary class="day-tile-summary">
<div class="day-tile-summary-main"> <div class="day-tile-summary-main">
<div class="day-tile-icon"><span class="ui-icon icon-calendar"></span></div> <div class="day-tile-icon"><span class="ui-icon {{ daypart_icon_class(section.daypart.slug) }}"></span></div>
<div> <div>
<h2>{{ section.daypart.name }}</h2> <h2>{{ section.daypart.name }}</h2>
{% if section.summary_items %} {% if section.summary_items %}
<p class="muted">{{ section.summary_items|join(', ') }}</p> <p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
{% else %} {% else %}
<p class="muted">Noch frei. Öffnen, wenn du etwas ergänzen möchtest.</p> <p class="muted">Noch frei. Öffnen, wenn du etwas ergänzen möchtest.</p>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<span class="status-pill">{{ section.entries|length }} geplant</span> <span class="status-pill{% if section.entries %} status-home{% endif %}">{{ section.entries|length }} geplant</span>
</summary> </summary>
<div class="day-tile-body"> <div class="day-tile-body">
{% if section.selected_quick_action %} {% if section.selected_quick_action %}
<div class="suggestion-card"> <div class="suggestion-card selected-quick-action">
<span class="status-pill status-home">Schon ausgewählt</span>
<strong>{{ section.selected_quick_action.title }}</strong> <strong>{{ section.selected_quick_action.title }}</strong>
<p class="muted">{{ section.selected_quick_action.subtitle }}</p> <p class="muted">{{ section.selected_quick_action.subtitle }}</p>
{% if section.selected_quick_action.type == 'existing' %} {% if section.selected_quick_action.type == 'existing' %}
@@ -150,6 +177,19 @@
<h3>Passt gut dazu</h3> <h3>Passt gut dazu</h3>
<div class="quick-add-row compact-quick-row"> <div class="quick-add-row compact-quick-row">
{% for suggestion in section.recipe_suggestions %} {% for suggestion in section.recipe_suggestions %}
{% if suggestion.existing_item_id %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ suggestion.existing_item_id }}">
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.planner_generated_meal') }}"> <form method="post" action="{{ url_for('main.planner_generated_meal') }}">
{{ csrf_input() }} {{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}"> <input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
@@ -164,6 +204,7 @@
<small>{{ suggestion.reason }}</small> <small>{{ suggestion.reason }}</small>
</button> </button>
</form> </form>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -175,7 +216,7 @@
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}" data-filter-limit="3"> <input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}" data-filter-limit="3">
</label> </label>
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}"> <div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
{% for item in section.food_candidates %} {% for item in section.search_candidates %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}"> <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() }} {{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}"> <input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
@@ -184,7 +225,10 @@
<input type="hidden" name="visibility" value="{{ item.visibility }}"> <input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="picker-row" type="submit"> <button class="picker-row" type="submit">
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
{% if item.availability_state == 'home' %}<small>zuhause</small>{% endif %} <small>
{{ item_kind_labels[item.kind] }}
{% if item.availability_state == 'home' %} · zuhause{% endif %}
</small>
</button> </button>
</form> </form>
{% endfor %} {% endfor %}
@@ -217,11 +261,45 @@
{% if entry.note %} {% if entry.note %}
<p>{{ entry.note }}</p> <p>{{ entry.note }}</p>
{% endif %} {% endif %}
{% if entry.can_edit %}
<details class="planner-entry-edit">
<summary class="ghost-button">Anpassen</summary>
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<label>
Für wen?
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if entry.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="wide">
Notiz
<input type="text" name="note" value="{{ entry.note or '' }}" placeholder="Optional">
</label>
<button type="submit">Speichern</button>
</form>
</details>
{% endif %}
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="empty-state">Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.</p> <p class="empty-state">Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.</p>
{% if section.is_snack_daypart %}
<div class="row-actions snack-inline-actions">
<button
class="ghost-button"
type="button"
data-day-snack-hide
data-target="#daypart-{{ section.daypart.id }}"
>
Wieder ausblenden
</button>
</div>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</details> </details>
+196 -12
View File
@@ -10,6 +10,13 @@
<div class="week-nav"> <div class="week-nav">
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a> <a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
<span>{{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }}</span> <span>{{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }}</span>
<details class="export-menu">
<summary class="ghost-button export-menu-trigger">PDF exportieren</summary>
<div class="export-menu-panel">
<a href="{{ url_for('main.planner_export_pdf', week=week_start.isoformat(), mode='mine') }}">Meinen Essensplan</a>
<a href="{{ url_for('main.planner_export_pdf', week=week_start.isoformat(), mode='household') }}">Unseren Essensplan</a>
</div>
</details>
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Nächste Woche</a> <a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Nächste Woche</a>
</div> </div>
</section> </section>
@@ -80,36 +87,213 @@
{% endif %} {% endif %}
</div> </div>
{% if card.filled_dayparts %} {% if not card.filled_dayparts %}
<p class="week-card-count">{{ card.planned_count }} Einträge</p> <p class="empty-state week-card-empty-copy">Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.</p>
<div class="chip-row"> {% endif %}
{% for slot in card.filled_dayparts %}
<span class="chip">{{ slot.name }} · {{ slot.count }}</span> {% if card.hidden_snack_slots %}
<div class="week-card-snack-actions" data-week-snack-actions>
<div>
<p class="eyebrow">Snacks ergänzen</p>
</div>
<div class="chip-row snack-reveal-actions">
{% for hidden_slot in card.hidden_snack_slots %}
<button
class="ghost-button snack-reveal-button"
type="button"
data-week-snack-slot-open
data-target="#week-slot-{{ card.date.isoformat() }}-{{ hidden_slot.id }}"
>
{% if hidden_slot.name == 'Vormittagssnack' %}
Vormittag
{% elif hidden_slot.name == 'Nachmittagssnack' %}
Nachmittag
{% elif hidden_slot.name == 'Später Snack' %}
Abend
{% else %}
{{ hidden_slot.name }}
{% endif %}
</button>
{% endfor %} {% endfor %}
</div> </div>
<p class="muted">{{ card.preview_items | join(', ') }}</p> </div>
{% else %}
<p class="empty-state">Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.</p>
{% endif %} {% endif %}
<div class="week-slot-stack"> <div class="week-slot-stack">
{% for slot in card.slots %} {% for slot in card.slots %}
<div class="week-slot drop-slot" data-target-date="{{ card.date.isoformat() }}" data-target-daypart-id="{{ slot.daypart.id }}"> <div
class="week-slot drop-slot{% if slot.entries %} has-entries{% endif %}{% if slot.is_snack_daypart %} week-slot-snack{% endif %}"
id="week-slot-{{ card.date.isoformat() }}-{{ slot.daypart.id }}"
data-target-date="{{ card.date.isoformat() }}"
data-target-daypart-id="{{ slot.daypart.id }}"
{% if slot.is_snack_daypart and not slot.visible_by_default %}hidden data-week-snack-slot{% endif %}
>
<div class="week-slot-head"> <div class="week-slot-head">
<div class="week-slot-title">
<span class="ui-icon {{ daypart_icon_class(slot.daypart.slug) }}"></span>
<strong>{{ slot.daypart.name }}</strong> <strong>{{ slot.daypart.name }}</strong>
<span>{{ slot.entries|length }}</span> </div>
<div class="week-slot-head-meta">
<span class="week-slot-count{% if slot.entries %} status-home{% endif %}">{{ slot.entries|length }}</span>
<button class="week-slot-add" type="button" data-week-slot-picker-open aria-label="{{ slot.daypart.name }} an {{ weekday_name(card.date) }} direkt ergänzen">+</button>
</div>
</div>
<div class="week-slot-picker" hidden>
<div class="week-slot-picker-head">
<strong>{{ slot.daypart.name }} ergänzen</strong>
<button class="ghost-button week-slot-picker-close" type="button" data-week-slot-picker-close>Schließen</button>
</div>
<label class="planner-search week-slot-picker-search">
<span>Suche</span>
<input type="text" placeholder="Mahlzeiten oder Ideen suchen" data-filter-input data-filter-target="#week-slot-picker-list-{{ card.date.isoformat() }}-{{ slot.daypart.id }}">
</label>
<div id="week-slot-picker-list-{{ card.date.isoformat() }}-{{ slot.daypart.id }}">
{% if slot.picker.meal_candidates %}
<div class="planner-subsection">
<h3>Mahlzeitenideen</h3>
<div class="quick-add-row compact-quick-row">
{% for item in slot.picker.meal_candidates %}
<form method="post" action="{{ url_for('main.planner_day', date=card.date.isoformat()) }}" class="js-week-slot-submit" data-filter-label="{{ item.name|lower }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ slot.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 slot.picker.recipe_suggestions %}
<div class="planner-subsection">
<h3>Passt gut dazu</h3>
<div class="quick-add-row compact-quick-row">
{% for suggestion in slot.picker.recipe_suggestions %}
{% if suggestion.existing_item_id %}
<form method="post" action="{{ url_for('main.planner_day', date=card.date.isoformat()) }}" class="js-week-slot-submit" data-filter-label="{{ suggestion.title|lower }} {{ suggestion.reason|lower }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
<input type="hidden" name="item_id" value="{{ suggestion.existing_item_id }}">
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('main.planner_generated_meal') }}" class="js-week-slot-submit" data-filter-label="{{ suggestion.title|lower }} {{ suggestion.reason|lower }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
<input type="hidden" name="visibility" value="{{ suggestion.visibility or '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>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% if not slot.picker.meal_candidates and not slot.picker.recipe_suggestions %}
<p class="empty-state">Hier ist gerade noch nichts vorbereitet. Im Tagesplan kannst du jederzeit etwas Neues anlegen.</p>
{% endif %}
</div>
</div> </div>
{% if slot.entries %} {% if slot.entries %}
<div class="week-entry-stack"> <div class="week-entry-stack">
{% for entry in slot.entries %} {% 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) }}"> <article
class="plan-chip draggable-plan-entry{% if entry.can_edit %} is-editable{% endif %}"
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) }}"
{% if entry.can_edit %}
data-week-entry-open
data-week-entry-dialog-id="week-entry-dialog-{{ entry.id }}"
tabindex="0"
role="button"
aria-label="{{ entry.item_name }} bearbeiten"
{% endif %}
>
<strong>{{ entry.item_name }}</strong> <strong>{{ entry.item_name }}</strong>
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small> <small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
</article> </article>
{% if entry.can_edit %}
<dialog class="week-entry-dialog" id="week-entry-dialog-{{ entry.id }}">
<div class="week-entry-dialog-card">
<div class="week-entry-dialog-head">
<div>
<h3>{{ entry.item_name }}</h3>
<p>{{ slot.daypart.name }} · {{ weekday_name(card.date) }}, {{ card.date.strftime('%d.%m.%Y') }}</p>
</div>
<button class="ghost-button" type="button" data-week-entry-close>Schließen</button>
</div>
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form js-week-entry-submit">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
<input type="hidden" name="return_week" value="{{ week_start.isoformat() }}">
<label>
Für wen?
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if entry.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="wide">
Notiz
<input type="text" name="note" value="{{ entry.note or '' }}" placeholder="Optional">
</label>
<div class="week-entry-dialog-actions">
<button type="submit">Speichern</button>
</div>
</form>
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id) }}" class="week-entry-remove-form js-week-entry-submit">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
<input type="hidden" name="return_week" value="{{ week_start.isoformat() }}">
<button class="ghost-button" type="submit">Eintrag entfernen</button>
</form>
</div>
</dialog>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<div class="week-slot-actions">
{% if slot.copy_allowed %}
<form method="post" action="{{ url_for('main.planner_slot_copy_forward') }}" class="js-copy-forward-form">
{{ csrf_input() }}
<input type="hidden" name="source_date" value="{{ card.date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
<button class="ghost-button week-slot-copy" type="submit">Zum nächsten Tag kopieren</button>
</form>
{% endif %}
</div>
{% else %} {% else %}
<p class="week-slot-empty">Hierher ziehen</p> <div class="week-slot-empty">
<p>Hierher ziehen</p>
{% if slot.is_snack_daypart %}
<button
class="ghost-button week-slot-hide"
type="button"
data-week-snack-slot-hide
data-target="#week-slot-{{ card.date.isoformat() }}-{{ slot.daypart.id }}"
>
Wieder ausblenden
</button>
{% endif %}
</div>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
+1
View File
@@ -133,6 +133,7 @@
<fieldset> <fieldset>
<legend>Alltag</legend> <legend>Alltag</legend>
<label class="inline-check"><input type="checkbox" name="push_small_snack" value="1" {% if user_settings.push_small_snack %}checked{% endif %}><span>Am Nachmittag an etwas Kleines erinnern</span></label>
<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_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="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_templates" value="1" {% if user_settings.suggest_templates %}checked{% endif %}><span>Häufig genutzte Tages- und Wochenvorlagen vorschlagen</span></label>
+1
View File
@@ -2,3 +2,4 @@ Flask==3.1.1
gunicorn==23.0.0 gunicorn==23.0.0
pywebpush==2.3.0 pywebpush==2.3.0
Pillow==11.2.1; python_version < "3.14" Pillow==11.2.1; python_version < "3.14"
fpdf2==2.8.3
+1 -1
View File
@@ -20,4 +20,4 @@ if [ "${NOURI_RUN_REMINDER_WORKER:-1}" = "1" ]; then
) & ) &
fi fi
exec gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 4 wsgi:app exec gunicorn --bind 0.0.0.0:8000 --workers 1 --threads 4 wsgi:app