5 Commits

16 changed files with 580 additions and 80 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.1.1",
"upstreamVersion": "1.0.0", "upstreamVersion": "1.1.1",
"healthCheckPath": "/", "healthCheckPath": "/",
"httpPort": 8000, "httpPort": 8000,
"manifestVersion": 2, "manifestVersion": 2,
+27
View File
@@ -0,0 +1,27 @@
# Nouri 1.1.1
Nouri 1.1.1 ist ein kleiner Feinschliff-Release. Der Schwerpunkt liegt auf saubereren Bezeichnungen in der Oberfläche und einer einheitlichen Versionsanhebung für App und Cloudron-Paket.
## Highlights
- Beschriftungen im Plan werden wieder korrekt großgeschrieben, zum Beispiel `Mahlzeitideen`
- App-Version und Cloudron-Version stehen jetzt auf `1.1.1`
- Versions-Fallback in der App wurde an den neuen Stand angepasst
## Neu in 1.1.1
### 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 App-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.
+32 -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,7 @@ 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"
def load_secret_key(data_dir: Path) -> str: def load_secret_key(data_dir: Path) -> str:
@@ -54,11 +56,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.1.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 +103,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,6 +144,7 @@ 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()],
+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"))
+22 -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.1.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
@@ -203,6 +205,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 (
@@ -454,6 +469,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)
+250 -40
View File
@@ -4,6 +4,7 @@ from collections import defaultdict
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
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,
@@ -79,8 +80,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
@@ -172,19 +175,54 @@ 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,
"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",
@@ -319,7 +357,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 +1272,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 +1299,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 +1390,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 +1536,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]
] ]
@@ -1719,6 +1859,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 +1868,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),
@@ -2338,6 +2480,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 +2557,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():
@@ -3521,6 +3699,38 @@ 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"))
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")
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")
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):
+9 -8
View File
@@ -13,9 +13,9 @@ 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"},
] ]
@@ -92,10 +92,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:
@@ -155,7 +156,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"])
+9
View File
@@ -94,6 +94,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,
+48 -1
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;
@@ -937,6 +938,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 {
@@ -952,6 +978,23 @@ legend {
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;
@@ -1505,6 +1548,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;
+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 %}
+2 -2
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">
@@ -91,7 +91,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">
+18
View File
@@ -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>
+41 -2
View File
@@ -150,6 +150,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 +177,7 @@
<small>{{ suggestion.reason }}</small> <small>{{ suggestion.reason }}</small>
</button> </button>
</form> </form>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -175,7 +189,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 +198,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,6 +234,28 @@
{% 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>
+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