5 Commits

16 changed files with 580 additions and 80 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen",
"version": "1.0.0",
"upstreamVersion": "1.0.0",
"version": "1.1.1",
"upstreamVersion": "1.1.1",
"healthCheckPath": "/",
"httpPort": 8000,
"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
import json
import os
import secrets
from datetime import date, timedelta
@@ -34,6 +35,7 @@ from .main import main_bp
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
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:
@@ -54,11 +56,38 @@ def load_secret_key(data_dir: Path) -> str:
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:
root_dir = Path(__file__).resolve().parent.parent
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
upload_dir = data_dir / "uploads"
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)
ensure_upload_structure(upload_dir)
@@ -74,7 +103,8 @@ def create_app() -> Flask:
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
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"),
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_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,
"today": date.today(),
"app_version": app.config["APP_VERSION"],
"app_release_url": app.config["RELEASE_URL"],
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
+28
View File
@@ -289,3 +289,31 @@ def category_update(category_id: int):
get_db().commit()
flash("Die Zuordnung wurde aktualisiert.", "success")
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
CURRENT_SCHEMA_VERSION = "1.0.0"
CURRENT_SCHEMA_VERSION = "1.1.1"
def get_db() -> sqlite3.Connection:
@@ -18,9 +18,11 @@ def get_db() -> sqlite3.Connection:
g.db = sqlite3.connect(
current_app.config["DATABASE_PATH"],
detect_types=sqlite3.PARSE_DECLTYPES,
timeout=30,
)
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA foreign_keys = ON")
g.db.execute("PRAGMA busy_timeout = 30000")
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(
"""
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)
"""
)
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)
+254 -44
View File
@@ -4,6 +4,7 @@ from collections import defaultdict
from datetime import date, datetime, timedelta
from itertools import product
from pathlib import Path
import sqlite3
from flask import (
Blueprint,
@@ -79,8 +80,10 @@ def refresh_due_context():
if getattr(g, "user", None) is None:
return None
if request.method == "GET" and endpoint.startswith("main."):
ensure_user_settings_row()
activate_due_shopping_needs()
try:
activate_due_shopping_needs()
except sqlite3.OperationalError:
current_app.logger.warning("Due shopping needs could not be activated during this request.")
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(
"INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)",
"INSERT INTO user_settings (user_id) VALUES (?)",
(g.user["id"],),
)
if commit:
get_db().commit()
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()
if row is None:
return {}
settings = dict(row)
return settings
settings.update(dict(row))
boolean_fields = {
"reminders_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_description"] = VISIBILITY_DESCRIPTIONS.get(entry.get("visibility"), "")
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"
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}))
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:
names = [item["name"] for item in combo]
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]:
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"}:
target_patterns = [
("carb", "dairy", "fruit"),
("carb", "dairy", "nuts"),
("carb", "dairy", "seeds"),
("carb", "fruit", "dairy"),
{
"slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
"reason": "Passt gut zu Frühstück oder Snack",
},
{
"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:
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] = []
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:
groups = [builder_groups.get(builder_key, []) for builder_key in pattern]
if any(not group for group in groups):
slot_candidates = []
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
for combo in product(*groups):
for combo in product(*slot_candidates):
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
seen_signatures.add(signature)
combo_items = list(combo)
suggestions.append(
{
"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],
"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:
return suggestions
if len(suggestions) >= limit * 3:
break
if len(suggestions) >= limit * 3:
break
return suggestions
def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) -> list[dict]:
settings = get_user_settings()
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
hidden_keys = fetch_hidden_generated_suggestion_keys()
home_foods = [
item
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_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] = []
seen_signatures: set[tuple[int, ...]] = set()
meals = [item for item in fetch_items(kind="meal") if item_matches_daypart(item, daypart_id)]
for meal in meals:
if meal["component_ids"] and all(component_id in home_food_ids for component_id in meal["component_ids"]):
signature = normalized_component_signature(meal["component_ids"])
if signature in seen_signatures:
continue
seen_signatures.add(signature)
component_items = [home_food_map[component_id] for component_id in meal["component_ids"] if component_id in home_food_map]
if not 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:
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)
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(
{
"title": meal["name"],
"reason": "Zuhause vorhanden",
"component_ids": meal["component_ids"],
"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": [],
"missing_components": [],
"needs_shopping": False,
"is_generated": False,
"suggestion_key": None,
"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):
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
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]
@@ -1403,6 +1536,13 @@ def build_daypart_suggestions(daypart_id: int) -> list[dict]:
"reason": "Für später vormerken",
"component_ids": [],
"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]
]
@@ -1719,6 +1859,7 @@ def build_day_planner_sections(
+ [item for item in candidates if item["kind"] == "food"],
limit=20,
)
search_candidates = dedupe_items(meal_candidates + food_candidates, limit=24)
entry_item_ids = [int(entry["item_id"]) for entry in entries]
sections.append(
{
@@ -1727,6 +1868,7 @@ def build_day_planner_sections(
"candidates": candidates,
"meal_candidates": meal_candidates,
"food_candidates": food_candidates,
"search_candidates": search_candidates,
"recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3),
"suggestions": build_daypart_suggestions(daypart["id"]),
"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():
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"))
@login_required
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}")
@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")
@login_required
def planner_remove(entry_id: int):
+9 -8
View File
@@ -13,9 +13,9 @@ from .push import send_push_message
MEAL_PUSH_RULES = [
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "label": "Frühstück"},
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "label": "Mittagessen"},
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "label": "Abendessen"},
{"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, "end_hour": 18, "label": "Mittagessen"},
{"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()
def due_for_rule(now: datetime, *, hour: int, minute: int) -> bool:
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
delta = (now - target).total_seconds()
return 0 <= delta < 180
def due_for_rule(now: datetime, *, hour: int, minute: int, end_hour: int) -> bool:
current_minutes = (now.hour * 60) + now.minute
target_minutes = (hour * 60) + minute
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:
@@ -155,7 +156,7 @@ def send_due_meal_pushes(now: datetime | None = None) -> int:
for rule in MEAL_PUSH_RULES:
if not settings.get(rule["setting"]):
continue
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"]):
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"], end_hour=rule["end_hour"]):
continue
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
);
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
+48 -1
View File
@@ -89,7 +89,8 @@ textarea {
}
button,
.button {
.button,
.ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -937,6 +938,31 @@ legend {
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-list-card,
.suggestion-card {
@@ -952,6 +978,23 @@ legend {
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 {
padding: 1rem;
border-radius: 18px;
@@ -1505,6 +1548,10 @@ legend {
min-width: 100%;
}
.planner-entry-inline-form {
grid-template-columns: 1fr;
}
.mobile-nav-stack {
position: fixed;
left: 0.75rem;
+47 -2
View File
@@ -78,8 +78,8 @@
const applyFilter = () => {
const term = input.value.trim().toLowerCase();
if (!term) {
items.forEach((item) => {
item.hidden = false;
items.forEach((item, index) => {
item.hidden = hasLimit ? index >= resultLimit : false;
});
syncGroups();
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", () => {
initMobileSheet();
initFilterInputs();
initIosPullToRefresh();
});
})();
+6
View File
@@ -66,6 +66,12 @@
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
</button>
</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>
</article>
{% endfor %}
+2 -2
View File
@@ -2,7 +2,7 @@
<html lang="de" data-theme="auto">
<head>
<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>
<meta name="theme-color" content="#de9862">
<meta name="mobile-web-app-capable" content="yes">
@@ -91,7 +91,7 @@
<footer class="site-footer">
<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>
</div>
<div class="footer-copy">
+19 -1
View File
@@ -134,8 +134,26 @@
<div>
<strong>{{ suggestion.title }}</strong>
<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>
{% 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>
<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>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article>
{% endfor %}
</div>
+19 -1
View File
@@ -51,8 +51,26 @@
<div>
<strong>{{ suggestion.title }}</strong>
<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>
{% 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>
<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>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article>
{% endfor %}
</div>
+55 -16
View File
@@ -150,20 +150,34 @@
<h3>Passt gut dazu</h3>
<div class="quick-add-row compact-quick-row">
{% for suggestion in section.recipe_suggestions %}
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
<input type="hidden" name="visibility" value="shared">
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% 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') }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
<input type="hidden" name="visibility" value="shared">
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% endif %}
{% endfor %}
</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">
</label>
<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 }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
@@ -184,7 +198,10 @@
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="picker-row" type="submit">
<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>
</form>
{% endfor %}
@@ -217,6 +234,28 @@
{% if entry.note %}
<p>{{ entry.note }}</p>
{% 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>
{% endfor %}
</div>
+1 -1
View File
@@ -20,4 +20,4 @@ if [ "${NOURI_RUN_REMINDER_WORKER:-1}" = "1" ]; then
) &
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