Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed14dd4aef | |||
| 35e6a7b56e |
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+94
-35
@@ -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",
|
||||
@@ -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]:
|
||||
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,
|
||||
}
|
||||
)
|
||||
if len(suggestions) >= limit:
|
||||
return suggestions
|
||||
if len(suggestions) >= limit * 3:
|
||||
break
|
||||
if len(suggestions) >= limit * 3:
|
||||
break
|
||||
return suggestions
|
||||
|
||||
|
||||
@@ -1719,6 +1776,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 +1785,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),
|
||||
|
||||
+47
-2
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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">
|
||||
</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 +184,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]|lower }}
|
||||
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||
</small>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user