2 Commits

Author SHA1 Message Date
hnzio ed14dd4aef Refine iOS refresh and disable pinch zoom 2026-04-12 20:40:40 +02:00
hnzio 35e6a7b56e Stabilize Cloudron SQLite and refine planner suggestions 2026-04-12 20:37:57 +02:00
8 changed files with 184 additions and 41 deletions
+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"))
+2
View File
@@ -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
+94 -35
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",
@@ -1239,57 +1277,76 @@ 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,
} }
) )
if len(suggestions) >= limit: if len(suggestions) >= limit * 3:
return suggestions break
if len(suggestions) >= limit * 3:
break
return suggestions return suggestions
@@ -1719,6 +1776,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 +1785,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),
+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 %}
+1 -1
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">
+5 -2
View File
@@ -175,7 +175,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 +184,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]|lower }}
{% if item.availability_state == 'home' %} · zuhause{% endif %}
</small>
</button> </button>
</form> </form>
{% endfor %} {% endfor %}
+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