diff --git a/CloudronManifest.json b/CloudronManifest.json index d82979c..47406c3 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,8 +4,8 @@ "author": "Florian Heinz", "description": "Private Flask app for meals, shopping and gentle food planning", "tagline": "einfach essen planen", - "version": "1.1.1", - "upstreamVersion": "1.1.1", + "version": "1.2.0", + "upstreamVersion": "1.2.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/RELEASE_NOTES_1.2.0.md b/RELEASE_NOTES_1.2.0.md new file mode 100644 index 0000000..8490286 --- /dev/null +++ b/RELEASE_NOTES_1.2.0.md @@ -0,0 +1,60 @@ +# Nouri 1.2.0 + +Nouri 1.2.0 bündelt die Weiterentwicklung seit 1.1.1 zu einem ruhigeren und alltagstauglicheren Planungs-Release. Der Fokus lag auf weniger Überforderung im Plan, besseren kleinen Erinnerungen und einem sauberen PDF-Export für den Wochenplan. + +## Neu in 1.2.0 + +### Snacks ruhiger im Tages- und Wochenplan + +- Hauptmahlzeiten bleiben immer sichtbar. +- Snack-Bereiche werden nur bei Bedarf eingeblendet. +- Leere Snack-Slots lassen sich wieder ausblenden. +- In der Wochenansicht wurden die Snack-Aktionen sprachlich gestrafft: + - `Snacks ergänzen` + - `Vormittag` + - `Nachmittag` + - `Abend` + +### Bessere visuelle Betonung im Plan + +- Ausgewählte und eingetragene Mahlzeiten werden im Tagesplan klarer hervorgehoben. +- Die Wochenansicht betont gefüllte Slots jetzt ähnlich wie die Tagesansicht. +- Snack-Slots fügen sich in der Wochenansicht stimmiger ein und wirken ruhiger. + +### Kleine tägliche Snack-Erinnerung + +- Neue Option in den Einstellungen: + - `Am Nachmittag an etwas Kleines erinnern` +- Wenn noch kein Snack geplant ist, kann Nouri einmal täglich eine kleine Push-Erinnerung schicken. +- Die Push-Nachricht nimmt zuerst passende Mahlzeitenideen und sonst einfache Kombinationen aus dem, was zuhause da ist. + +### Wochenplan als PDF exportieren + +- Die Wochenansicht kann jetzt als PDF exportiert werden. +- Der Export ist schlicht und druckfreundlich gehalten. +- Es gibt zwei Varianten: + - `Meinen Essensplan` + - `Unseren Essensplan` +- Im gemeinsamen PDF werden persönliche Einträge mit echten Namen gekennzeichnet, zum Beispiel `Für Flo`. +- Snack-Zeilen erscheinen nur dann, wenn sie in der Woche tatsächlich genutzt werden. + +### Export-Menü vereinfacht + +- Statt zwei einzelner Export-Buttons gibt es jetzt einen einzigen Button: + - `PDF exportieren` +- Darunter öffnet sich eine kleine Auswahl für die beiden PDF-Varianten. + +## Technische Änderungen + +- `fpdf2` wurde als Abhängigkeit ergänzt. +- Cloudron-Version und Upstream-Version stehen jetzt auf `1.2.0`. +- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.2.0` angehoben. + +## Betroffene Bereiche + +- Tagesplan +- Wochenansicht +- Push-Erinnerungen +- Einstellungen +- PDF-Export +- Cloudron-Paketierung diff --git a/nouri/__init__.py b/nouri/__init__.py index 3cd6eb0..152a6ee 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -74,7 +74,7 @@ def load_app_version(root_dir: Path) -> str: ).strip() if manifest_version: return manifest_version - return "1.1.1" + return "1.2.0" def load_release_url() -> str: diff --git a/nouri/db.py b/nouri/db.py index 4ed36a1..d8f3166 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -10,7 +10,7 @@ from werkzeug.security import generate_password_hash from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS -CURRENT_SCHEMA_VERSION = "1.1.1" +CURRENT_SCHEMA_VERSION = "1.2.0" def get_db() -> sqlite3.Connection: @@ -162,6 +162,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: push_missing_breakfast INTEGER NOT NULL DEFAULT 0, push_missing_lunch INTEGER NOT NULL DEFAULT 0, push_missing_dinner INTEGER NOT NULL DEFAULT 0, + push_small_snack INTEGER NOT NULL DEFAULT 0, suggest_home_for_today INTEGER NOT NULL DEFAULT 1, remind_small_snack INTEGER NOT NULL DEFAULT 0, remind_nuts INTEGER NOT NULL DEFAULT 0, @@ -256,6 +257,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0") + add_column_if_missing(database, "user_settings", "push_small_snack INTEGER NOT NULL DEFAULT 0") def ensure_default_household(database: sqlite3.Connection) -> int: @@ -381,6 +383,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0") + add_column_if_missing(database, "user_settings", "push_small_snack INTEGER NOT NULL DEFAULT 0") if default_owner_id is not None: database.execute( @@ -431,6 +434,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL") database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL") database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL") + database.execute("UPDATE user_settings SET push_small_snack = 0 WHERE push_small_snack IS NULL") database.execute( """ diff --git a/nouri/main.py b/nouri/main.py index 6954236..65df970 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import defaultdict from datetime import date, datetime, timedelta +from io import BytesIO from itertools import product from pathlib import Path import sqlite3 @@ -72,6 +73,13 @@ VISIBILITY_FORM_OPTIONS = [ ] TARGET_USER_OPTIONS_DEFAULT = "__all__" WEEKDAY_LABELS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] +PRIMARY_DAYPART_SLUGS = {"breakfast", "lunch", "dinner"} +SNACK_DAYPART_SLUGS = {"morning-snack", "afternoon-snack", "late-snack"} +PDF_DAYPART_LABELS = { + "morning-snack": "Snack am Vormittag", + "afternoon-snack": "Snack am Nachmittag", + "late-snack": "Später Snack", +} @main_bp.before_app_request @@ -95,6 +103,10 @@ def get_dayparts() -> list: return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall() +def format_weekday(day_value: date) -> str: + return WEEKDAY_LABELS[day_value.weekday()] + + def get_household_users(active_only: bool = True): query = """ SELECT id, username, display_name, role @@ -193,6 +205,7 @@ def default_user_settings() -> dict: "push_missing_breakfast": False, "push_missing_lunch": False, "push_missing_dinner": False, + "push_small_snack": False, "suggest_home_for_today": True, "remind_small_snack": False, "remind_nuts": False, @@ -235,6 +248,7 @@ def get_user_settings() -> dict: "push_missing_breakfast", "push_missing_lunch", "push_missing_dinner", + "push_small_snack", "suggest_home_for_today", "remind_small_snack", "remind_nuts", @@ -1816,7 +1830,7 @@ def build_selected_quick_action( return { "type": "existing", "title": selected_item["name"], - "subtitle": "Bereit zum Eintragen", + "subtitle": "Ausgewählt. Du kannst es jetzt direkt eintragen.", "item_id": int(selected_item["id"]), "visibility": selected_item["visibility"], "daypart_id": daypart_id, @@ -1826,7 +1840,7 @@ def build_selected_quick_action( return { "type": "generated", "title": selected_meal_name, - "subtitle": "Vorgeschlagen aus dem, was zuhause da ist", + "subtitle": "Ausgewählt aus dem, was zuhause gut passt.", "component_ids": selected_component_ids, "visibility": "shared", "daypart_id": daypart_id, @@ -1881,6 +1895,9 @@ def build_day_planner_sections( candidates=candidates, ), "is_open": selected_daypart_id == daypart["id"], + "is_primary_daypart": daypart["slug"] in PRIMARY_DAYPART_SLUGS, + "is_snack_daypart": daypart["slug"] in SNACK_DAYPART_SLUGS, + "visible_by_default": daypart["slug"] in PRIMARY_DAYPART_SLUGS or bool(entries) or selected_daypart_id == daypart["id"], "summary_items": [entry["item_name"] for entry in entries][:2], "default_visibility": "shared", } @@ -1912,7 +1929,20 @@ def build_template_day_sections(selected_map: dict[int, list[int]] | None = None def fetch_week_cards(week_start: date): days = [week_start + timedelta(days=index) for index in range(7)] + week_end = week_start + timedelta(days=6) grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6)) + picker_map = {} + for daypart in get_dayparts(): + candidates = fetch_plan_candidates(int(daypart["id"])) + meal_candidates = dedupe_items( + [item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"] + + [item for item in candidates if item["kind"] == "meal"], + limit=4, + ) + picker_map[int(daypart["id"])] = { + "meal_candidates": meal_candidates, + "recipe_suggestions": build_home_recipe_suggestions(int(daypart["id"]), limit=3), + } cards = [] for current_day in days: filled_dayparts = [] @@ -1921,11 +1951,27 @@ def fetch_week_cards(week_start: date): slots = [] for daypart in get_dayparts(): slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), []) - slots.append({"daypart": dict(daypart), "entries": slot_entries}) + is_snack_daypart = daypart["slug"] in SNACK_DAYPART_SLUGS + visible_by_default = (not is_snack_daypart) or bool(slot_entries) + slots.append( + { + "daypart": dict(daypart), + "entries": slot_entries, + "copy_allowed": bool(slot_entries) and current_day < week_end, + "picker": picker_map.get(int(daypart["id"]), {"meal_candidates": [], "recipe_suggestions": []}), + "is_snack_daypart": is_snack_daypart, + "visible_by_default": visible_by_default, + } + ) if slot_entries: filled_dayparts.append({"id": daypart["id"], "name": daypart["name"], "count": len(slot_entries)}) planned_count += len(slot_entries) preview_items.extend(entry["item_name"] for entry in slot_entries[:2]) + hidden_snack_slots = [ + {"id": int(slot["daypart"]["id"]), "name": slot["daypart"]["name"]} + for slot in slots + if slot["is_snack_daypart"] and not slot["visible_by_default"] + ] cards.append( { "date": current_day, @@ -1933,11 +1979,161 @@ def fetch_week_cards(week_start: date): "planned_count": planned_count, "preview_items": preview_items[:4], "slots": slots, + "hidden_snack_slots": hidden_snack_slots, } ) return cards +def format_week_pdf_entry(entry: dict) -> str: + return entry["item_name"] + + +def normalize_pdf_export_mode(raw: str | None) -> str: + return "household" if raw == "household" else "mine" + + +def fetch_plan_entries_for_range_export(start_date: date, end_date: date, *, mode: str): + params: list[object] = [start_date.isoformat(), end_date.isoformat()] + if mode == "household": + where_clause = "plan_entries.household_id = ?" + params.append(current_household_id()) + else: + where_clause = visible_clause("plan_entries") + params.extend(visible_params()) + + rows = get_db().execute( + f""" + SELECT plan_entries.*, + items.name AS item_name, + items.kind AS item_kind, + items.photo_filename, + items.availability_state, + dayparts.name AS daypart_name, + dayparts.slug AS daypart_slug, + dayparts.sort_order, + owner.display_name AS owner_display_name, + owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username + FROM plan_entries + JOIN items ON items.id = plan_entries.item_id + JOIN dayparts ON dayparts.id = plan_entries.daypart_id + LEFT JOIN users AS owner ON owner.id = plan_entries.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id + WHERE plan_date BETWEEN ? AND ? AND {where_clause} + ORDER BY plan_date, dayparts.sort_order, items.name + """, + params, + ).fetchall() + grouped = defaultdict(list) + for row in describe_records(rows): + grouped[(row["plan_date"], row["daypart_id"])].append(row) + return grouped + + +def format_week_pdf_entry(entry: dict, *, mode: str) -> str: + label = entry["item_name"] + if mode == "household": + if entry.get("target_name"): + return f"{label} (Für {entry['target_name']})" + if entry.get("is_personal"): + return f"{label} (Für {entry['owner_name']})" + return f"{label} (Für alle)" + + if entry.get("is_shared"): + return f"{label} (gemeinsam)" + return label + + +def build_week_pdf_rows(week_start: date, *, mode: str) -> tuple[list, list[list[str]]]: + days = [week_start + timedelta(days=index) for index in range(7)] + grouped_entries = fetch_plan_entries_for_range_export(week_start, week_start + timedelta(days=6), mode=mode) + rows: list[list[str]] = [] + dayparts = get_dayparts() + visible_dayparts = [] + for daypart in dayparts: + row_cells: list[str] = [] + has_content = False + row_label = PDF_DAYPART_LABELS.get(daypart["slug"], daypart["name"]) + row = [daypart["name"]] + for current_day in days: + entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), []) + cell_value = "\n".join(format_week_pdf_entry(entry, mode=mode) for entry in entries) + row_cells.append(cell_value) + has_content = has_content or bool(cell_value.strip()) + + if daypart["slug"] in PRIMARY_DAYPART_SLUGS or has_content: + visible_dayparts.append([row_label, *row_cells]) + + return days, visible_dayparts + + +def build_week_plan_pdf(week_start: date, *, mode: str = "mine") -> bytes: + try: + from fpdf import FPDF + from fpdf.fonts import FontFace + except ImportError as exc: # pragma: no cover - depends on optional package in local env + raise RuntimeError("Für den PDF-Export fehlt noch die Abhängigkeit aus der requirements.txt.") from exc + + mode = normalize_pdf_export_mode(mode) + week_end = week_start + timedelta(days=6) + week_number = week_start.isocalendar().week + days, rows = build_week_pdf_rows(week_start, mode=mode) + plan_label = "Mein Essensplan" if mode == "mine" else "Unser Essensplan" + + pdf = FPDF(orientation="L", unit="mm", format="A4") + pdf.set_auto_page_break(auto=True, margin=14) + pdf.set_margins(left=14, top=14, right=14) + pdf.add_page() + + pdf.set_title(f"{plan_label} KW {week_number:02d}") + pdf.set_author("Nouri") + pdf.set_creator("Nouri") + + pdf.set_font("Helvetica", "B", 18) + pdf.cell(0, 9, f"{plan_label} vom {week_start.strftime('%d.%m.%Y')} bis {week_end.strftime('%d.%m.%Y')}", new_x="LMARGIN", new_y="NEXT") + pdf.set_font("Helvetica", "", 11) + pdf.set_text_color(82, 82, 82) + pdf.cell(0, 6, f"KW {week_number:02d}", new_x="LMARGIN", new_y="NEXT") + pdf.ln(3) + pdf.set_text_color(20, 20, 20) + + headings = [" "] + [f"{format_weekday(day)}\n{day.strftime('%d.%m.%Y')}" for day in days] + first_column_width = 34 + remaining_width = pdf.w - pdf.l_margin - pdf.r_margin - first_column_width + day_column_width = remaining_width / 7 + column_widths = (first_column_width, *([day_column_width] * 7)) + + header_style = FontFace(emphasis="B", fill_color=(240, 240, 240)) + first_column_style = FontFace(emphasis="B", fill_color=(248, 248, 248)) + body_style = FontFace(fill_color=(255, 255, 255)) + + with pdf.table( + borders_layout="SINGLE_TOP_LINE", + cell_fill_color=(255, 255, 255), + cell_fill_mode="ROWS", + col_widths=column_widths, + gutter_height=0, + gutter_width=0, + headings_style=header_style, + line_height=5.5, + text_align=("LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT", "LEFT"), + width=pdf.w - pdf.l_margin - pdf.r_margin, + ) as table: + header_row = table.row() + for heading in headings: + header_row.cell(heading, padding=(2.8, 2.5, 2.8, 2.5), v_align="M") + + for row in rows: + table_row = table.row() + table_row.cell(row[0], style=first_column_style, padding=(2.5, 2.8, 2.5, 2.8), v_align="M") + for value in row[1:]: + table_row.cell(value or " ", style=body_style, padding=(2.5, 2.8, 2.5, 2.8), v_align="TOP") + + return bytes(pdf.output()) + + def count_visible_items(availability_state: str) -> int: row = get_db().execute( f"SELECT COUNT(*) AS count FROM items WHERE availability_state = ? AND {visible_clause('items')}", @@ -2927,6 +3123,7 @@ def settings_view(): push_missing_breakfast = ?, push_missing_lunch = ?, push_missing_dinner = ?, + push_small_snack = ?, suggest_home_for_today = ?, remind_small_snack = ?, remind_nuts = ?, @@ -2951,6 +3148,7 @@ def settings_view(): parse_checkbox("push_missing_breakfast", False), parse_checkbox("push_missing_lunch", False), parse_checkbox("push_missing_dinner", False), + parse_checkbox("push_small_snack", False), parse_checkbox("suggest_home_for_today", True), parse_checkbox("remind_small_snack", False), parse_checkbox("remind_nuts", False), @@ -3602,6 +3800,28 @@ def planner(): ) +@main_bp.get("/planner/export.pdf") +@login_required +def planner_export_pdf(): + week_start = parse_week_start(request.args.get("week")) + mode = normalize_pdf_export_mode(request.args.get("mode")) + try: + pdf_bytes = build_week_plan_pdf(week_start, mode=mode) + except RuntimeError as exc: + flash(str(exc), "error") + return redirect(url_for("main.planner", week=week_start.isoformat())) + + week_number = week_start.isocalendar().week + prefix = "mein-essensplan" if mode == "mine" else "unser-essensplan" + filename = f"{prefix}-kw-{week_number:02d}-{week_start.year}.pdf" + return send_file( + BytesIO(pdf_bytes), + mimetype="application/pdf", + as_attachment=True, + download_name=filename, + ) + + @main_bp.route("/planner/day", methods=("GET", "POST")) @login_required def planner_day(): @@ -3813,3 +4033,85 @@ def planner_move(entry_id: int): "redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()), } ) + + +@main_bp.post("/planner/slot/copy-forward") +@login_required +def planner_slot_copy_forward(): + source_date = parse_plan_date(request.form.get("source_date")) + target_date = source_date + timedelta(days=1) + daypart_raw = request.form.get("daypart_id", "").strip() + if not daypart_raw.isdigit(): + flash("Die Tageszeit konnte nicht erkannt werden.", "error") + return redirect(url_for("main.planner", week=parse_week_start(source_date.isoformat()).isoformat())) + + daypart_id = int(daypart_raw) + entries = 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.plan_date = ? AND plan_entries.daypart_id = ? AND {visible_clause('plan_entries')} + ORDER BY plan_entries.id + """, + [source_date.isoformat(), daypart_id, *visible_params()], + ).fetchall() + + copied_count = 0 + shopping_added = 0 + shopping_scheduled = 0 + + for raw_entry in entries: + entry = describe_record(dict(raw_entry)) + try: + ensure_can_edit(entry, "Diesen Planeintrag kannst du gerade nicht kopieren.") + except PermissionError: + continue + + duplicate = get_db().execute( + """ + SELECT id + FROM plan_entries + WHERE household_id = ? + AND plan_date = ? + AND daypart_id = ? + AND item_id = ? + AND visibility = ? + AND COALESCE(note, '') = COALESCE(?, '') + LIMIT 1 + """, + ( + current_household_id(), + target_date.isoformat(), + daypart_id, + entry["item_id"], + entry["visibility"], + entry.get("note", ""), + ), + ).fetchone() + if duplicate: + continue + + shopping_result = insert_plan_entry( + item_id=entry["item_id"], + daypart_id=daypart_id, + plan_date=target_date, + visibility=entry["visibility"], + note=entry.get("note", "") or "", + ) + copied_count += 1 + shopping_added += int(shopping_result["count"]) + shopping_scheduled += int(shopping_result["scheduled_count"]) + + if copied_count == 0: + flash("Für diese Tageszeit gab es nichts Neues zum Kopieren.", "info") + else: + if shopping_added: + flash("Fehlende Lebensmittel wurden für den passenden Einkauf ergänzt.", "info") + elif shopping_scheduled: + flash("Fehlende Lebensmittel wurden für später vorgemerkt.", "info") + flash(f"{copied_count} Eintrag{' wurde' if copied_count == 1 else 'e wurden'} zum nächsten Tag kopiert.", "success") + + return redirect(url_for("main.planner", week=parse_week_start(source_date.isoformat()).isoformat())) diff --git a/nouri/reminders.py b/nouri/reminders.py index d36d93c..a344b2e 100644 --- a/nouri/reminders.py +++ b/nouri/reminders.py @@ -18,6 +18,15 @@ MEAL_PUSH_RULES = [ {"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "end_hour": 24, "label": "Abendessen"}, ] +SNACK_PUSH_RULE = { + "slugs": ("morning-snack", "afternoon-snack", "late-snack"), + "setting": "push_small_snack", + "hour": 15, + "minute": 0, + "end_hour": 20, + "label": "Etwas Kleines", +} + def current_local_time() -> datetime: timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin") @@ -73,6 +82,24 @@ def plan_exists_for_daypart(user, *, planned_date: date, daypart_id: int) -> boo return bool(int(row["count"] or 0)) +def plan_exists_for_any_daypart(user, *, planned_date: date, daypart_ids: list[int]) -> bool: + if not daypart_ids: + return False + placeholders = ", ".join("?" for _ in daypart_ids) + row = get_db().execute( + f""" + SELECT COUNT(*) AS count + FROM plan_entries + WHERE household_id = ? + AND plan_date = ? + AND daypart_id IN ({placeholders}) + AND (visibility = 'shared' OR owner_user_id = ?) + """, + [int(user["household_id"]), planned_date.isoformat(), *daypart_ids, int(user["id"])], + ).fetchone() + return bool(int(row["count"] or 0)) + + def reminder_event_exists(user_id: int, event_key: str) -> bool: row = get_db().execute( "SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1", @@ -119,6 +146,13 @@ def build_push_message(label: str, suggestion: dict | None) -> tuple[str, str]: return title, f"Für {label.lower()} ist noch nichts geplant." +def build_small_snack_push_message(suggestion: dict | None) -> tuple[str, str]: + title = "Nouri · Etwas Kleines" + if suggestion and suggestion.get("title"): + return title, f"Für später wäre etwas Kleines möglich. Zuhause passt gerade: {suggestion['title']}." + return title, "Für später wäre etwas Kleines möglich. Vielleicht passt heute etwas Einfaches wie Nüsse oder ein Apfel." + + def best_suggestion_for_user(user, daypart_id: int) -> dict | None: previous_user = getattr(g, "user", None) g.user = user @@ -129,6 +163,19 @@ def best_suggestion_for_user(user, daypart_id: int) -> dict | None: return suggestions[0] if suggestions else None +def best_small_snack_suggestion_for_user(user, daypart_ids: list[int]) -> tuple[int | None, dict | None]: + previous_user = getattr(g, "user", None) + g.user = user + try: + for daypart_id in daypart_ids: + suggestions = build_home_recipe_suggestions(daypart_id, limit=1) + if suggestions: + return daypart_id, suggestions[0] + finally: + g.user = previous_user + return (daypart_ids[0] if daypart_ids else None), None + + def send_due_meal_pushes(now: datetime | None = None) -> int: now = now or current_local_time() planned_date = now.date() @@ -190,6 +237,50 @@ def send_due_meal_pushes(now: datetime | None = None) -> int: mark_reminder_event(int(user["id"]), event_key) sent_count += 1 + snack_rule = SNACK_PUSH_RULE + if settings.get(snack_rule["setting"]) and due_for_rule( + now, + hour=snack_rule["hour"], + minute=snack_rule["minute"], + end_hour=snack_rule["end_hour"], + ): + snack_daypart_ids = [ + int(dayparts[slug]["id"]) + for slug in snack_rule["slugs"] + if slug in dayparts + ] + if snack_daypart_ids and not plan_exists_for_any_daypart( + user, + planned_date=planned_date, + daypart_ids=snack_daypart_ids, + ): + event_key = f"meal-push:{planned_date.isoformat()}:small-snack" + if not reminder_event_exists(int(user["id"]), event_key): + daypart_id, suggestion = best_small_snack_suggestion_for_user(user, snack_daypart_ids) + title, body = build_small_snack_push_message(suggestion) + url = build_push_target_url( + planned_date=planned_date, + daypart_id=daypart_id or snack_daypart_ids[0], + suggestion=suggestion, + ) + + delivered = False + for subscription in subscriptions: + ok, _error = send_push_message( + { + "endpoint": subscription["endpoint"], + "keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]}, + }, + title=title, + body=body, + url=url, + ) + delivered = delivered or ok + + if delivered: + mark_reminder_event(int(user["id"]), event_key) + sent_count += 1 + return sent_count diff --git a/nouri/schema.sql b/nouri/schema.sql index ac275b4..d904aac 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -61,6 +61,7 @@ CREATE TABLE IF NOT EXISTS user_settings ( push_missing_breakfast INTEGER NOT NULL DEFAULT 0, push_missing_lunch INTEGER NOT NULL DEFAULT 0, push_missing_dinner INTEGER NOT NULL DEFAULT 0, + push_small_snack INTEGER NOT NULL DEFAULT 0, suggest_home_for_today INTEGER NOT NULL DEFAULT 1, remind_small_snack INTEGER NOT NULL DEFAULT 0, remind_nuts INTEGER NOT NULL DEFAULT 0, diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index 631ff3a..fa97237 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -853,6 +853,38 @@ legend { overflow: hidden; } +.day-tile.has-selection { + border-color: color-mix(in srgb, var(--accent) 34%, var(--line) 66%); + box-shadow: 0 20px 36px rgba(94, 68, 49, 0.16); +} + +.day-tile.has-entries { + position: relative; + border-color: color-mix(in srgb, var(--accent) 24%, var(--line) 76%); + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface) 90%, #ffe8d8 10%), color-mix(in srgb, var(--surface) 96%, #fff 4%)); + box-shadow: 0 18px 34px rgba(94, 68, 49, 0.14); +} + +.day-tile.has-entries::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: linear-gradient(180deg, color-mix(in srgb, var(--accent-strong) 76%, white 24%), color-mix(in srgb, var(--accent) 72%, transparent 28%)); + opacity: 0.9; +} + +.day-tile.has-entries .day-tile-summary { + background: + linear-gradient(180deg, rgba(255, 236, 221, 0.28), rgba(255, 255, 255, 0)); +} + +.day-tile.has-entries .status-pill { + background: color-mix(in srgb, var(--mint-soft) 78%, var(--surface) 22%); + border: 1px solid color-mix(in srgb, var(--mint-soft) 54%, var(--line) 46%); +} + .day-tile > summary::-webkit-details-marker { display: none; } @@ -892,11 +924,85 @@ legend { height: 1.15rem; } +.day-tile.has-entries .day-tile-icon { + background: linear-gradient(145deg, rgba(255, 255, 255, 0.98), color-mix(in srgb, var(--accent-soft) 68%, #fff 32%)); + box-shadow: 0 10px 22px rgba(94, 68, 49, 0.14); +} + +.day-tile-summary-text { + margin: 0.2rem 0 0; + color: color-mix(in srgb, var(--text) 84%, white 16%); + font-size: 1.08rem; +} + +.day-tile.has-entries .day-tile-summary-text { + color: color-mix(in srgb, var(--text) 90%, white 10%); + font-weight: 600; +} + +[data-theme="dark"] .day-tile.has-entries { + border-color: color-mix(in srgb, var(--accent) 30%, var(--line) 70%); + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface) 96%, #3f3430 4%), color-mix(in srgb, var(--surface) 100%, #000 0%)); + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.26); +} + +[data-theme="dark"] .day-tile.has-entries .day-tile-summary { + background: + linear-gradient(90deg, rgba(243, 177, 125, 0.10), rgba(243, 177, 125, 0.03) 38%, rgba(255, 255, 255, 0) 68%); +} + +[data-theme="dark"] .day-tile.has-entries .day-tile-icon { + background: linear-gradient(145deg, rgba(255, 255, 255, 0.12), rgba(243, 177, 125, 0.16)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +[data-theme="dark"] .day-tile.has-entries .status-pill { + background: rgba(155, 198, 175, 0.20); + border-color: rgba(155, 198, 175, 0.16); +} + +[data-theme="dark"] .day-tile.has-entries .day-tile-summary-text { + color: #f3ece7; +} + .day-tile-body { padding: 0 1.25rem 1.25rem; border-top: 1px solid var(--line); } +.snack-reveal-panel { + padding: 1rem 1.1rem; +} + +.snack-reveal-actions { + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.snack-reveal-button { + padding: 0.58rem 0.9rem; +} + +.week-card-snack-actions { + display: grid; + gap: 0.7rem; + margin: 0.2rem 0 0.95rem; + padding: 0.8rem 0.9rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 94%, var(--surface-strong) 6%); +} + +.week-card-snack-actions .eyebrow { + margin: 0; +} + +.week-card-empty-copy { + margin-bottom: 0.95rem; +} + .quick-add-row { display: flex; flex-wrap: wrap; @@ -972,6 +1078,12 @@ legend { background: color-mix(in srgb, var(--surface-strong) 84%, #fff 16%); } +.selected-quick-action { + background: linear-gradient(180deg, color-mix(in srgb, var(--accent-soft) 82%, #fff 18%), color-mix(in srgb, var(--surface-strong) 82%, #fff 18%)); + border-color: color-mix(in srgb, var(--accent) 36%, var(--line) 64%); + box-shadow: 0 16px 30px rgba(94, 68, 49, 0.12); +} + .template-list-card, .week-template-row { display: grid; @@ -1148,24 +1260,99 @@ legend { align-items: flex-start; } -.week-card-count { - font-size: 1.25rem; - font-family: var(--font-heading); - margin: 0.8rem 0 0.2rem; -} - .week-card-actions { margin-top: 1rem; } +.export-menu { + position: relative; +} + +.export-menu > summary { + list-style: none; +} + +.export-menu > summary::-webkit-details-marker { + display: none; +} + +.export-menu-trigger::after { + content: "▾"; + font-size: 0.8rem; + opacity: 0.7; +} + +.export-menu[open] .export-menu-trigger { + background: var(--accent-soft); +} + +.export-menu-panel { + position: absolute; + top: calc(100% + 0.45rem); + right: 0; + z-index: 14; + min-width: 13.5rem; + display: grid; + gap: 0.15rem; + padding: 0.45rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 96%, #fff 4%); + box-shadow: var(--shadow); +} + +.export-menu-panel a { + display: block; + padding: 0.8rem 0.9rem; + border-radius: 14px; + color: var(--text); + text-decoration: none; +} + +.export-menu-panel a:hover { + background: var(--accent-soft); +} + +.week-card { + position: relative; + overflow: visible; +} + +.week-card.has-open-picker { + z-index: 6; +} + .week-slot { + position: relative; padding: 0.85rem; border-radius: 18px; - background: color-mix(in srgb, var(--surface-strong) 80%, #fff 20%); + background: linear-gradient(180deg, color-mix(in srgb, var(--surface-strong) 84%, #fff 16%), color-mix(in srgb, var(--surface) 90%, #fff 10%)); border: 1px solid var(--line); transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; } +.week-slot.has-entries { + border-color: color-mix(in srgb, var(--accent) 24%, var(--line) 76%); + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface) 90%, #ffe8d8 10%), color-mix(in srgb, var(--surface) 96%, #fff 4%)); + box-shadow: 0 18px 34px rgba(94, 68, 49, 0.12); +} + +.week-slot.has-entries::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + border-radius: 18px 0 0 18px; + background: linear-gradient(180deg, color-mix(in srgb, var(--accent-strong) 76%, white 24%), color-mix(in srgb, var(--accent) 72%, transparent 28%)); + opacity: 0.9; +} + +.week-slot.week-slot-snack.has-entries { + background: + linear-gradient(180deg, color-mix(in srgb, var(--surface) 92%, #ffe3cf 8%), color-mix(in srgb, var(--surface) 98%, #fff 2%)); +} + .week-slot.is-drag-over { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 60%, var(--line) 40%); @@ -1180,11 +1367,91 @@ legend { margin-bottom: 0.5rem; } +.week-slot-head-meta { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.week-slot-count { + min-width: 1.9rem; + text-align: center; + font-weight: 700; + color: var(--muted); +} + +.week-slot.has-entries .week-slot-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 2rem; + padding: 0 0.55rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--mint-soft) 54%, var(--line) 46%); + background: color-mix(in srgb, var(--mint-soft) 78%, var(--surface) 22%); + color: color-mix(in srgb, var(--text) 86%, #173127 14%); +} + +.week-slot-add { + width: 1.9rem; + height: 1.9rem; + display: inline-grid; + place-items: center; + padding: 0; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--accent-soft); + color: var(--text); + font-weight: 700; + font-size: 1.15rem; + line-height: 1; + text-align: center; +} + +.week-slot-add:hover { + background: color-mix(in srgb, var(--accent-soft) 72%, #fff 28%); +} + +.week-slot-picker { + position: absolute; + top: calc(100% + 0.55rem); + left: 0; + right: 0; + z-index: 12; + display: grid; + gap: 0.9rem; + padding: 0.95rem; + border-radius: 18px; + border: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 96%, #fff 4%); + box-shadow: var(--shadow); +} + +.week-slot-picker[hidden] { + display: none; +} + +.week-slot-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.week-slot-picker-close { + padding: 0.5rem 0.85rem; +} + +.week-slot-picker-search { + margin-bottom: 0.1rem; +} + .plan-chip { padding: 0.7rem 0.8rem; border-radius: 16px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 246, 239, 0.92)); - border: 1px solid var(--line); + background: linear-gradient(180deg, color-mix(in srgb, var(--surface) 72%, #fff 28%), color-mix(in srgb, var(--accent-soft) 55%, var(--surface) 45%)); + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--line) 82%); cursor: grab; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); } @@ -1203,11 +1470,115 @@ legend { transform: scale(0.98); } +.week-slot-actions { + display: flex; + justify-content: flex-end; + margin-top: 0.65rem; +} + +.week-slot-copy { + padding: 0.55rem 0.85rem; +} + .plan-chip small, .week-slot-empty { color: var(--muted); } +.week-slot-empty { + display: grid; + justify-items: start; + gap: 0.65rem; + padding: 0.85rem; + border-radius: 16px; + border: 1px dashed color-mix(in srgb, var(--line) 74%, var(--accent) 26%); + background: color-mix(in srgb, var(--surface) 92%, #fff 8%); +} + +.week-slot-empty p { + margin: 0; +} + +[data-theme="dark"] .week-slot { + background: linear-gradient(180deg, rgba(66, 57, 54, 0.96), rgba(58, 50, 48, 0.98)); + border-color: rgba(243, 177, 125, 0.14); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +[data-theme="dark"] .week-card-snack-actions { + background: rgba(47, 40, 38, 0.72); + border-color: rgba(243, 177, 125, 0.10); +} + +[data-theme="dark"] .export-menu-panel { + background: rgba(43, 37, 35, 0.98); + border-color: rgba(243, 177, 125, 0.14); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.34); +} + +[data-theme="dark"] .export-menu-panel a:hover { + background: rgba(243, 177, 125, 0.10); +} + +[data-theme="dark"] .week-slot.has-entries { + border-color: rgba(243, 177, 125, 0.18); + background: + linear-gradient(180deg, rgba(70, 60, 57, 0.98), rgba(58, 50, 48, 0.99)); + box-shadow: 0 18px 34px rgba(0, 0, 0, 0.24); +} + +[data-theme="dark"] .week-slot.week-slot-snack.has-entries { + background: + linear-gradient(180deg, rgba(75, 64, 60, 0.98), rgba(60, 52, 49, 0.99)); +} + +[data-theme="dark"] .week-slot.has-entries .week-slot-count { + border-color: rgba(155, 198, 175, 0.16); + background: rgba(155, 198, 175, 0.20); + color: #eef8f2; +} + +[data-theme="dark"] .week-slot.is-drag-over { + background: linear-gradient(180deg, rgba(87, 71, 64, 0.98), rgba(72, 58, 53, 0.98)); + border-color: rgba(243, 177, 125, 0.24); +} + +[data-theme="dark"] .week-slot-add { + background: rgba(243, 177, 125, 0.16); + border-color: rgba(243, 177, 125, 0.18); + color: #f7efe9; +} + +[data-theme="dark"] .week-slot-add:hover { + background: rgba(243, 177, 125, 0.22); +} + +[data-theme="dark"] .week-slot-picker { + background: rgba(43, 37, 35, 0.98); + border-color: rgba(243, 177, 125, 0.14); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.34); +} + +[data-theme="dark"] .plan-chip { + background: linear-gradient(180deg, rgba(86, 72, 66, 0.98), rgba(72, 60, 56, 0.98)); + border-color: rgba(243, 177, 125, 0.18); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +[data-theme="dark"] .week-slot-copy { + background: rgba(255, 255, 255, 0.03); + border-color: rgba(243, 177, 125, 0.12); +} + +[data-theme="dark"] .week-slot-copy:hover { + background: rgba(243, 177, 125, 0.10); +} + +[data-theme="dark"] .week-slot-empty { + background: rgba(58, 50, 48, 0.72); + border-color: rgba(243, 177, 125, 0.16); +} + .flash-stack { display: grid; gap: 0.7rem; diff --git a/nouri/static/js/planner.js b/nouri/static/js/planner.js index 89694d4..077c23d 100644 --- a/nouri/static/js/planner.js +++ b/nouri/static/js/planner.js @@ -4,6 +4,38 @@ return meta ? meta.getAttribute("content") : ""; }; + const scrollKey = "nouri-week-scroll"; + + const rememberScroll = () => { + sessionStorage.setItem(scrollKey, String(window.scrollY)); + }; + + const restoreScroll = () => { + const savedScroll = sessionStorage.getItem(scrollKey); + if (!savedScroll) return; + sessionStorage.removeItem(scrollKey); + window.requestAnimationFrame(() => { + window.scrollTo({ top: Number(savedScroll), left: 0, behavior: "auto" }); + }); + }; + + const postAndRefreshInPlace = async (form) => { + const payload = new URLSearchParams(new FormData(form)); + const response = await fetch(form.action, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + }, + body: payload.toString(), + }); + if (!response.ok) { + throw new Error("request failed"); + } + rememberScroll(); + window.location.reload(); + }; + const initWeekDragAndDrop = () => { const board = document.querySelector(".week-board"); if (!board) return; @@ -75,7 +107,185 @@ }); }; + const initWeekCopyForward = () => { + document.querySelectorAll(".js-copy-forward-form").forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await postAndRefreshInPlace(form); + } catch (_error) { + window.location.reload(); + } + }); + }); + }; + + const initWeekSlotPicker = () => { + const board = document.querySelector(".week-board"); + if (!board) return; + + const closeAllPickers = () => { + board.querySelectorAll(".week-card").forEach((card) => { + card.classList.remove("has-open-picker"); + }); + board.querySelectorAll(".week-slot").forEach((slot) => { + slot.classList.remove("is-picker-open"); + }); + board.querySelectorAll(".week-slot-picker").forEach((picker) => { + picker.hidden = true; + }); + }; + + board.querySelectorAll("[data-week-slot-picker-open]").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + const slot = button.closest(".week-slot"); + if (!slot) return; + const picker = slot.querySelector(".week-slot-picker"); + if (!picker) return; + const card = slot.closest(".week-card"); + const shouldOpen = picker.hidden; + closeAllPickers(); + if (shouldOpen) { + picker.hidden = false; + slot.classList.add("is-picker-open"); + if (card) { + card.classList.add("has-open-picker"); + } + const filterInput = picker.querySelector("[data-filter-input]"); + if (filterInput instanceof HTMLInputElement) { + filterInput.value = ""; + filterInput.dispatchEvent(new Event("input", { bubbles: true })); + window.requestAnimationFrame(() => filterInput.focus()); + } + } + }); + }); + + board.querySelectorAll("[data-week-slot-picker-close]").forEach((button) => { + button.addEventListener("click", () => { + const slot = button.closest(".week-slot"); + if (!slot) return; + const picker = slot.querySelector(".week-slot-picker"); + const card = slot.closest(".week-card"); + if (!picker) return; + picker.hidden = true; + slot.classList.remove("is-picker-open"); + if (card) { + card.classList.remove("has-open-picker"); + } + }); + }); + + board.querySelectorAll(".js-week-slot-submit").forEach((form) => { + form.addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await postAndRefreshInPlace(form); + } catch (_error) { + window.location.reload(); + } + }); + }); + + document.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest(".week-slot")) return; + closeAllPickers(); + }); + }; + + const syncActionContainerVisibility = (container) => { + if (!(container instanceof HTMLElement)) return; + const hasVisibleButtons = Array.from(container.querySelectorAll("button")).some((button) => { + return !button.hidden; + }); + container.hidden = !hasVisibleButtons; + }; + + const revealActionButton = (container, selector) => { + if (!(container instanceof HTMLElement) || !selector) return; + const button = container.querySelector(`button[data-target="${selector}"]`); + if (!(button instanceof HTMLButtonElement)) return; + button.hidden = false; + container.hidden = false; + }; + + const initDaySnackReveal = () => { + document.querySelectorAll("[data-day-snack-open]").forEach((button) => { + button.addEventListener("click", () => { + const selector = button.getAttribute("data-target"); + if (!selector) return; + const tile = document.querySelector(selector); + if (!(tile instanceof HTMLDetailsElement)) return; + tile.hidden = false; + tile.open = true; + button.hidden = true; + tile.scrollIntoView({ block: "nearest", inline: "nearest" }); + syncActionContainerVisibility(button.closest("[data-day-snack-actions]")); + }); + }); + + document.querySelectorAll("[data-day-snack-hide]").forEach((button) => { + button.addEventListener("click", () => { + const selector = button.getAttribute("data-target"); + if (!selector) return; + const tile = document.querySelector(selector); + if (!(tile instanceof HTMLDetailsElement)) return; + tile.open = false; + tile.hidden = true; + revealActionButton(document.querySelector("[data-day-snack-actions]"), selector); + }); + }); + }; + + const initWeekSnackReveal = () => { + document.querySelectorAll("[data-week-snack-slot-open]").forEach((button) => { + button.addEventListener("click", () => { + const selector = button.getAttribute("data-target"); + if (!selector) return; + const slot = document.querySelector(selector); + if (!(slot instanceof HTMLElement)) return; + slot.hidden = false; + button.hidden = true; + syncActionContainerVisibility(button.closest("[data-week-snack-actions]")); + const openButton = slot.querySelector("[data-week-slot-picker-open]"); + if (openButton instanceof HTMLButtonElement) { + openButton.click(); + } else { + slot.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + }); + }); + + document.querySelectorAll("[data-week-snack-slot-hide]").forEach((button) => { + button.addEventListener("click", () => { + const selector = button.getAttribute("data-target"); + if (!selector) return; + const slot = document.querySelector(selector); + if (!(slot instanceof HTMLElement)) return; + const picker = slot.querySelector(".week-slot-picker"); + if (picker instanceof HTMLElement) { + picker.hidden = true; + } + slot.classList.remove("is-picker-open"); + slot.hidden = true; + const card = slot.closest(".week-card"); + if (card) { + card.classList.remove("has-open-picker"); + } + revealActionButton(slot.closest(".week-card")?.querySelector("[data-week-snack-actions]"), selector); + }); + }); + }; + document.addEventListener("DOMContentLoaded", () => { + restoreScroll(); initWeekDragAndDrop(); + initWeekCopyForward(); + initWeekSlotPicker(); + initDaySnackReveal(); + initWeekSnackReveal(); }); })(); diff --git a/nouri/templates/planner/day.html b/nouri/templates/planner/day.html index 96aacf8..bece3f5 100644 --- a/nouri/templates/planner/day.html +++ b/nouri/templates/planner/day.html @@ -54,26 +54,53 @@
+ {% set hidden_snack_sections = sections | selectattr('is_snack_daypart') | rejectattr('visible_by_default') | list %} + {% if hidden_snack_sections %} +
+
+

Zwischenmahlzeit hinzufügen

+
+
+ {% for section in hidden_snack_sections %} + + {% endfor %} +
+
+ {% endif %} + {% for section in sections %} -
+

{{ section.daypart.name }}

{% if section.summary_items %} -

{{ section.summary_items|join(', ') }}

+

{{ section.summary_items|join(', ') }}

{% else %}

Noch frei. Öffnen, wenn du etwas ergänzen möchtest.

{% endif %}
- {{ section.entries|length }} geplant + {{ section.entries|length }} geplant
{% if section.selected_quick_action %} -
+
+ Schon ausgewählt {{ section.selected_quick_action.title }}

{{ section.selected_quick_action.subtitle }}

{% if section.selected_quick_action.type == 'existing' %} @@ -261,6 +288,18 @@
{% else %}

Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.

+ {% if section.is_snack_daypart %} +
+ +
+ {% endif %} {% endif %}
diff --git a/nouri/templates/planner/week.html b/nouri/templates/planner/week.html index 963409e..faa26c3 100644 --- a/nouri/templates/planner/week.html +++ b/nouri/templates/planner/week.html @@ -10,6 +10,13 @@
Vorige Woche {{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }} +
+ PDF exportieren + +
Nächste Woche
@@ -80,24 +87,125 @@ {% endif %} - {% if card.filled_dayparts %} -

{{ card.planned_count }} Einträge

-
- {% for slot in card.filled_dayparts %} - {{ slot.name }} · {{ slot.count }} - {% endfor %} + {% if not card.filled_dayparts %} +

Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.

+ {% endif %} + + {% if card.hidden_snack_slots %} +
+
+

Snacks ergänzen

+
+
+ {% for hidden_slot in card.hidden_snack_slots %} + + {% endfor %} +
-

{{ card.preview_items | join(', ') }}

- {% else %} -

Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.

{% endif %}
{% for slot in card.slots %} -
+
{{ slot.daypart.name }} - {{ slot.entries|length }} +
+ {{ slot.entries|length }} + +
+
+ {% if slot.entries %}
@@ -108,8 +216,30 @@ {% endfor %}
+
+ {% if slot.copy_allowed %} +
+ {{ csrf_input() }} + + + +
+ {% endif %} +
{% else %} -

Hierher ziehen

+
+

Hierher ziehen

+ {% if slot.is_snack_daypart %} + + {% endif %} +
{% endif %}
{% endfor %} diff --git a/nouri/templates/settings.html b/nouri/templates/settings.html index dd4381d..e8543e8 100644 --- a/nouri/templates/settings.html +++ b/nouri/templates/settings.html @@ -133,6 +133,7 @@
Alltag + diff --git a/requirements.txt b/requirements.txt index 182c53d..34430bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Flask==3.1.1 gunicorn==23.0.0 pywebpush==2.3.0 Pillow==11.2.1; python_version < "3.14" +fpdf2==2.8.3