release nouri 1.0.0

This commit is contained in:
2026-04-12 19:18:55 +02:00
parent b0d1cee5f5
commit 325101da99
17 changed files with 769 additions and 45 deletions
+179 -9
View File
@@ -27,10 +27,14 @@ from .constants import (
DEFAULT_CATEGORY_BUILDERS,
DAY_TEMPLATE_NAME_SUGGESTIONS,
DEFAULT_CATEGORIES,
ENERGY_DENSITY_LABELS,
ENERGY_DENSITY_OPTIONS,
ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS,
ITEM_SET_NAME_SUGGESTIONS,
NOTIFICATION_CHANNEL_OPTIONS,
SUGGESTION_STYLE_LABELS,
SUGGESTION_STYLE_OPTIONS,
VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS,
WEEKDAY_OPTIONS,
@@ -190,6 +194,9 @@ def get_user_settings() -> dict:
"show_planned_not_shopped",
"remind_tomorrow_if_sparse",
"remind_week_if_sparse",
"push_missing_breakfast",
"push_missing_lunch",
"push_missing_dinner",
"suggest_home_for_today",
"remind_small_snack",
"remind_nuts",
@@ -200,6 +207,8 @@ def get_user_settings() -> dict:
for field in boolean_fields:
settings[field] = bool(settings.get(field))
settings["notification_channel"] = settings.get("notification_channel") or "in_app"
settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced")
settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"])
return settings
@@ -220,6 +229,24 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") ->
return raw if raw in allowed else default
def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str:
allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS}
if raw == "easy" or raw == "snack":
return "balanced"
return raw if raw in allowed else default
def normalize_energy_density(raw: str | None, default: str = "neutral") -> str:
allowed = {value for value, _label in ENERGY_DENSITY_OPTIONS}
return raw if raw in allowed else default
def suggestion_style_energy_preference(style: str) -> str:
if style == "fitness":
return "low"
return "neutral"
def visible_clause(table_alias: str) -> str:
return (
f"{table_alias}.household_id = ? "
@@ -284,6 +311,8 @@ def describe_record(entry: dict) -> dict:
target_name = user_display_name(entry.get("target_display_name"), entry.get("target_username")) if entry.get("target_user_id") else None
entry["owner_name"] = owner_name
entry["target_name"] = target_name
entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral")
entry["energy_density_label"] = ENERGY_DENSITY_LABELS.get(entry["energy_density"], ENERGY_DENSITY_LABELS["neutral"])
entry["is_personal"] = entry.get("visibility") == "personal"
entry["is_shared"] = entry.get("visibility") == "shared"
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
@@ -501,6 +530,44 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
return builder_map
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
builder_keys = {key for item in component_items for key in item.get("builder_keys", ["neutral"])}
energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items]
score = 0
style = settings.get("suggestion_style", "balanced")
if style == "fitness":
score += 9 if "protein" in builder_keys else 0
score += 4 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0
score += 2 if "carb" in builder_keys else 0
elif style == "protein":
score += 8 if "protein" in builder_keys else 0
score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0
else:
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
score += 5 if "carb" in builder_keys else 0
score += 4 if builder_keys & {"dairy", "fruit", "nuts", "seeds"} else 0
else:
score += 5 if "protein" in builder_keys else 0
score += 4 if "carb" in builder_keys else 0
score += 4 if "veg" in builder_keys else 0
energy_preference = settings.get("energy_preference", "neutral")
if style == "fitness":
score += energy_values.count("low") * 4
score -= energy_values.count("high") * 2
elif energy_preference == "high":
score += energy_values.count("high") * 3
score -= energy_values.count("low")
elif energy_preference == "low":
score += energy_values.count("low") * 3
score -= energy_values.count("high")
else:
score += energy_values.count("neutral")
return score
def fetch_items(
*,
kind: str | None = None,
@@ -589,6 +656,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
{
"name": request.form.get("name", "").strip(),
"category": request.form.get("category", "").strip(),
"energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")),
"note": request.form.get("note", "").strip(),
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
"target_user_id": normalize_target_user_id(request.form.get("target_user_id")),
@@ -598,6 +666,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict:
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
"quick_food_name": request.form.get("quick_food_name", "").strip(),
"quick_food_category": request.form.get("quick_food_category", "").strip(),
"quick_food_energy_density": normalize_energy_density(request.form.get("quick_food_energy_density"), form_data.get("quick_food_energy_density", "neutral")),
"quick_food_note": request.form.get("quick_food_note", "").strip(),
}
)
@@ -608,9 +677,9 @@ def create_quick_food_from_form(form_data: dict) -> int:
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, created_by, updated_by
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, created_by, updated_by
)
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
@@ -619,6 +688,7 @@ def create_quick_food_from_form(form_data: dict) -> int:
form_data["visibility"],
form_data["quick_food_name"],
form_data["quick_food_category"],
form_data["quick_food_energy_density"],
form_data["quick_food_note"],
g.user["id"],
g.user["id"],
@@ -1178,11 +1248,13 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
target_patterns = [
("carb", "dairy", "fruit"),
("carb", "dairy", "nuts"),
("carb", "dairy", "seeds"),
("carb", "fruit", "dairy"),
]
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:
@@ -1222,12 +1294,15 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
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 "")
home_foods = [
item
for item in fetch_items(kind="food", availability="home")
if item_matches_daypart(item, daypart_id)
]
home_food_ids = {item["id"] for item in home_foods}
home_food_map = {int(item["id"]): item for item in home_foods}
suggestions: list[dict] = []
seen_signatures: set[tuple[int, ...]] = set()
@@ -1238,26 +1313,37 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4)
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]
suggestions.append(
{
"title": meal["name"],
"reason": "Zuhause vorhanden",
"component_ids": meal["component_ids"],
"existing_item_id": meal["id"],
"score": score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings) + 40,
}
)
daypart_slug = (get_daypart_by_id(daypart_id)["slug"] if daypart_id and get_daypart_by_id(daypart_id) else "")
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:
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]
suggestion["score"] = score_suggestion_components(component_items, daypart_slug=daypart_slug, settings=settings)
suggestions.append(suggestion)
deduped: list[dict] = []
seen = set()
for suggestion in suggestions:
ranked_suggestions = sorted(
suggestions,
key=lambda suggestion: (
-int(suggestion.get("score", 0)),
0 if suggestion.get("existing_item_id") else 1,
suggestion["title"].lower(),
),
)
for suggestion in ranked_suggestions:
if suggestion["title"] in seen:
continue
seen.add(suggestion["title"])
@@ -1287,6 +1373,10 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
if first_missing in item.get("builder_keys", [])
]
home_matches = sorted(
home_matches,
key=lambda item: -score_suggestion_components([item], daypart["slug"], settings),
)
text_map = {
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.",
@@ -1362,7 +1452,7 @@ def build_dashboard_hints(today: date) -> list[str]:
hints.append("Für den nächsten Einkauf sind schon ein paar Dinge vorgemerkt.")
if settings.get("remind_nuts"):
nut_items = [item for item in fetch_items(kind="food", availability="home") if "nuts" in item.get("builder_keys", [])]
nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))]
if nut_items:
hints.append("Heute schon an Nüsse gedacht?")
@@ -1567,7 +1657,52 @@ def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]:
return result
def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None):
def build_selected_quick_action(
*,
daypart_id: int,
selected_item_id: int | None,
selected_meal_name: str,
selected_component_ids: list[int],
candidates: list[dict],
) -> dict | None:
if selected_item_id:
selected_item = next((item for item in candidates if int(item["id"]) == int(selected_item_id)), None)
if selected_item is None:
try:
selected_item = get_item(selected_item_id)
except ValueError:
selected_item = None
if selected_item is not None:
return {
"type": "existing",
"title": selected_item["name"],
"subtitle": "Bereit zum Eintragen",
"item_id": int(selected_item["id"]),
"visibility": selected_item["visibility"],
"daypart_id": daypart_id,
}
if selected_meal_name and selected_component_ids:
return {
"type": "generated",
"title": selected_meal_name,
"subtitle": "Vorgeschlagen aus dem, was zuhause da ist",
"component_ids": selected_component_ids,
"visibility": "shared",
"daypart_id": daypart_id,
}
return None
def build_day_planner_sections(
selected_date: date,
selected_item_id: int | None,
selected_daypart_id: int | None,
selected_meal_name: str = "",
selected_component_ids: list[int] | None = None,
):
selected_component_ids = selected_component_ids or []
sections = []
day_entries = fetch_day_plan_entries(selected_date)
for daypart in get_dayparts():
@@ -1596,6 +1731,13 @@ def build_day_planner_sections(selected_date: date, selected_item_id: int | None
"suggestions": build_daypart_suggestions(daypart["id"]),
"balance_suggestion": build_balance_suggestion(int(daypart["id"]), entry_item_ids),
"selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None,
"selected_quick_action": build_selected_quick_action(
daypart_id=int(daypart["id"]),
selected_item_id=selected_item_id if selected_daypart_id == daypart["id"] else None,
selected_meal_name=selected_meal_name if selected_daypart_id == daypart["id"] else "",
selected_component_ids=selected_component_ids if selected_daypart_id == daypart["id"] else [],
candidates=candidates,
),
"is_open": selected_daypart_id == daypart["id"],
"summary_items": [entry["item_name"] for entry in entries][:2],
"default_visibility": "shared",
@@ -2082,6 +2224,7 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict):
form_data.get("category") or form_data.get("quick_food_category")
),
form_data=form_data,
energy_density_options=ENERGY_DENSITY_OPTIONS,
visibility_options=VISIBILITY_FORM_OPTIONS,
target_user_options=get_target_user_options(),
)
@@ -2588,18 +2731,24 @@ def settings_view():
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
elif form_name == "reminders":
ensure_user_settings_row()
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
get_db().execute(
"""
UPDATE user_settings
SET reminders_enabled = ?,
push_enabled = ?,
notification_channel = ?,
suggestion_style = ?,
energy_preference = ?,
remind_before_shopping = ?,
remind_on_shopping_day = ?,
show_missing_for_upcoming_week = ?,
show_planned_not_shopped = ?,
remind_tomorrow_if_sparse = ?,
remind_week_if_sparse = ?,
push_missing_breakfast = ?,
push_missing_lunch = ?,
push_missing_dinner = ?,
suggest_home_for_today = ?,
remind_small_snack = ?,
remind_nuts = ?,
@@ -2613,12 +2762,17 @@ def settings_view():
parse_checkbox("reminders_enabled", True),
parse_checkbox("push_enabled", False),
normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
suggestion_style,
suggestion_style_energy_preference(suggestion_style),
parse_checkbox("remind_before_shopping", True),
parse_checkbox("remind_on_shopping_day", True),
parse_checkbox("show_missing_for_upcoming_week", True),
parse_checkbox("show_planned_not_shopped", True),
parse_checkbox("remind_tomorrow_if_sparse", True),
parse_checkbox("remind_week_if_sparse", True),
parse_checkbox("push_missing_breakfast", False),
parse_checkbox("push_missing_lunch", False),
parse_checkbox("push_missing_dinner", False),
parse_checkbox("suggest_home_for_today", True),
parse_checkbox("remind_small_snack", False),
parse_checkbox("remind_nuts", False),
@@ -2831,6 +2985,7 @@ def item_create(kind: str):
form_data = {
"name": request.args.get("name", "").strip(),
"category": "",
"energy_density": "neutral",
"note": "",
"visibility": "shared",
"target_user_id": None,
@@ -2840,6 +2995,7 @@ def item_create(kind: str):
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
"quick_food_name": "",
"quick_food_category": "",
"quick_food_energy_density": "neutral",
"quick_food_note": "",
}
@@ -2878,9 +3034,9 @@ def item_create(kind: str):
cursor = get_db().execute(
"""
INSERT INTO items (
household_id, owner_user_id, target_user_id, visibility, kind, name, category, note, photo_filename, created_by, updated_by
household_id, owner_user_id, target_user_id, visibility, kind, name, category, energy_density, note, photo_filename, created_by, updated_by
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
current_household_id(),
@@ -2890,6 +3046,7 @@ def item_create(kind: str):
kind,
form_data["name"],
form_data["category"],
form_data["energy_density"],
form_data["note"],
photo_filename,
g.user["id"],
@@ -2921,6 +3078,7 @@ def item_edit(item_id: int):
form_data = {
"name": item["name"],
"category": item["category"] or "",
"energy_density": item.get("energy_density") or "neutral",
"note": item["note"] or "",
"visibility": item["visibility"],
"target_user_id": item["target_user_id"],
@@ -2930,6 +3088,7 @@ def item_edit(item_id: int):
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
"quick_food_name": "",
"quick_food_category": "",
"quick_food_energy_density": "neutral",
"quick_food_note": "",
}
@@ -2970,6 +3129,7 @@ def item_edit(item_id: int):
UPDATE items
SET name = ?,
category = ?,
energy_density = ?,
note = ?,
visibility = ?,
target_user_id = ?,
@@ -2981,6 +3141,7 @@ def item_edit(item_id: int):
(
form_data["name"],
form_data["category"],
form_data["energy_density"],
form_data["note"],
form_data["visibility"],
form_data["target_user_id"],
@@ -3303,14 +3464,23 @@ def planner_day():
selected_item_raw = request.args.get("item_id", "").strip()
selected_daypart_raw = request.args.get("daypart_id", "").strip()
selected_meal_name = request.args.get("meal_name", "").strip()
selected_components_raw = request.args.get("component_ids", "").strip()
selected_item_id = int(selected_item_raw) if selected_item_raw.isdigit() else None
selected_daypart_id = int(selected_daypart_raw) if selected_daypart_raw.isdigit() else None
selected_component_ids = [int(value) for value in selected_components_raw.split(",") if value.isdigit()]
return render_template(
"planner/day.html",
selected_date=selected_date,
previous_day=selected_date - timedelta(days=1),
next_day=selected_date + timedelta(days=1),
sections=build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id),
sections=build_day_planner_sections(
selected_date,
selected_item_id,
selected_daypart_id,
selected_meal_name=selected_meal_name,
selected_component_ids=selected_component_ids,
),
today=date.today(),
visibility_options=VISIBILITY_FORM_OPTIONS,
day_templates=fetch_day_templates()[:6],