diff --git a/README.md b/README.md index 011318f..d1a626e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # Nouri -Nouri ist eine kleine private Flask-App fuer einen Haushalt, um Essensideen, Einkaeufe, vorhandene Lebensmittel und einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. +Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. -## Merkmale in Version 0.1 +## Merkmale in Version 0.2 - Lebensmittel und Mahlzeitenideen anlegen - Fotos lokal hochladen - Einkaufsliste mit Abhaken -- "Zuhause" als sichtbarer Vorrat -- Archiv zum spaeteren Wiederverwenden -- Tages- und Wochenplanung nach Tageszeiten -- einfache Benutzeranmeldung fuer einen Haushalt +- „Zuhause“ als sichtbarer Vorrat mit Tageszeit-Filtern +- Archiv mit Suche und schneller Wiederaufnahme +- Tagesplan mit schnellen Vorschlägen je Tageszeit +- Wochenansicht für die nächsten 7 Tage +- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen +- einfache Benutzeranmeldung für einen Haushalt ## Lokal starten @@ -21,18 +23,22 @@ pip install -r requirements.txt flask --app wsgi run --debug ``` -Dann `http://127.0.0.1:5000` oeffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/setup` anlegen. +Dann `http://127.0.0.1:5000` öffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/auth/setup` anlegen. ## Konfiguration -Die App legt Daten standardmaessig unter `./data` ab. +Die App legt Daten standardmäßig unter `./data` ab. Wichtige Umgebungsvariablen: -- `NOURI_SECRET_KEY`: Session-Secret fuer Produktion -- `NOURI_DATA_DIR`: Pfad fuer Datenbank und Uploads, z. B. `/app/data` auf Cloudron +- `NOURI_SECRET_KEY`: Session-Secret für Produktion +- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` +## Migration von 0.1 auf 0.2 + +Beim Start führt Nouri das Schema erneut mit `CREATE ... IF NOT EXISTS` aus und gleicht die festen Tageszeiten ab. Vorhandene Daten bleiben erhalten; neue Indizes und aktualisierte Tageszeit-Namen werden automatisch ergänzt. + ## Cloudron-Hinweis -Fuer Cloudron spaeter `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen. +Für Cloudron später `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen. diff --git a/nouri/__init__.py b/nouri/__init__.py index 751333f..7cc6225 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import secrets +from datetime import date, timedelta from pathlib import Path from flask import Flask, send_from_directory @@ -12,6 +13,10 @@ from .constants import CATEGORIES, DAYPARTS, ITEM_KIND_LABELS, ITEM_KIND_SINGULA from .main import main_bp +WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] +WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] + + def create_app() -> Flask: root_dir = Path(__file__).resolve().parent.parent data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve() @@ -28,6 +33,7 @@ def create_app() -> Flask: DATA_DIR=str(data_dir), UPLOAD_FOLDER=str(upload_dir), MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024, + PERMANENT_SESSION_LIFETIME=timedelta(days=30), ) db.init_app(app) @@ -43,6 +49,9 @@ def create_app() -> Flask: "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, "category_suggestions": CATEGORIES, "daypart_suggestions": DAYPARTS, + "today": date.today(), + "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], + "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], } @app.get("/uploads/") diff --git a/nouri/auth.py b/nouri/auth.py index 808d0de..cd291e2 100644 --- a/nouri/auth.py +++ b/nouri/auth.py @@ -1,6 +1,7 @@ from __future__ import annotations import functools +import secrets from flask import ( Blueprint, @@ -34,7 +35,7 @@ def login_required(view): def ensure_csrf_token() -> str: token = session.get("_csrf_token") if not token: - token = session["_csrf_token"] = __import__("secrets").token_hex(24) + token = session["_csrf_token"] = secrets.token_hex(24) return token @@ -43,7 +44,8 @@ def inject_csrf_input(): return { "csrf_input": lambda: Markup( f'' - ) + ), + "csrf_token_value": ensure_csrf_token(), } @@ -87,7 +89,7 @@ def setup(): elif not password: error = "Bitte ein Passwort vergeben." elif password != password_repeat: - error = "Die Passwoerter stimmen nicht ueberein." + error = "Die Passwörter stimmen nicht überein." if error is None: database = get_db() @@ -115,6 +117,7 @@ def login(): if request.method == "POST": username = request.form.get("username", "").strip().lower() password = request.form.get("password", "") + remember_me = request.form.get("remember_me") == "1" database = get_db() user = database.execute( "SELECT * FROM users WHERE username = ?", @@ -127,6 +130,8 @@ def login(): if error is None: session.clear() + # Opt-in long-lived session so the shared household device stays low-friction. + session.permanent = remember_me session["user_id"] = user["id"] ensure_csrf_token() return redirect(url_for("main.dashboard")) diff --git a/nouri/constants.py b/nouri/constants.py index ed3bb45..a5e75e4 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -1,20 +1,20 @@ DAYPARTS = [ - {"slug": "breakfast", "name": "Fruehstueck", "sort_order": 10}, + {"slug": "breakfast", "name": "Frühstück", "sort_order": 10}, {"slug": "morning-snack", "name": "Vormittagssnack", "sort_order": 20}, {"slug": "lunch", "name": "Mittagessen", "sort_order": 30}, {"slug": "afternoon-snack", "name": "Nachmittagssnack", "sort_order": 40}, {"slug": "dinner", "name": "Abendessen", "sort_order": 50}, - {"slug": "late-snack", "name": "Spaeter Snack", "sort_order": 60}, + {"slug": "late-snack", "name": "Später Snack", "sort_order": 60}, ] CATEGORIES = [ "Brot & Getreide", "Milchprodukt", "Obst", - "Gemuese", - "Eiweissquelle", + "Gemüse", + "Eiweißquelle", "Snack", - "Getraenk", + "Getränk", "Vorrat & Basics", "Warmes", "Kleines Essen", diff --git a/nouri/db.py b/nouri/db.py index 1d84037..07554af 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -23,20 +23,24 @@ def get_db() -> sqlite3.Connection: def close_db(_error=None) -> None: - db = g.pop("db", None) - if db is not None: - db.close() + database = g.pop("db", None) + if database is not None: + database.close() + + +def apply_schema(database: sqlite3.Connection) -> None: + schema_path = Path(__file__).with_name("schema.sql") + database.executescript(schema_path.read_text(encoding="utf-8")) + sync_dayparts(database) def init_db() -> None: database = get_db() - schema_path = Path(__file__).with_name("schema.sql") - database.executescript(schema_path.read_text(encoding="utf-8")) - seed_dayparts(database) + apply_schema(database) database.commit() -def seed_dayparts(database: sqlite3.Connection) -> None: +def sync_dayparts(database: sqlite3.Connection) -> None: for entry in DAYPARTS: database.execute( """ @@ -45,22 +49,19 @@ def seed_dayparts(database: sqlite3.Connection) -> None: """, (entry["slug"], entry["name"], entry["sort_order"]), ) + database.execute( + """ + UPDATE dayparts + SET name = ?, sort_order = ? + WHERE slug = ? + """, + (entry["name"], entry["sort_order"], entry["slug"]), + ) def init_db_if_needed(app: Flask) -> None: - db_path = Path(app.config["DATABASE_PATH"]) - needs_init = not db_path.exists() with app.app_context(): - if needs_init: - init_db() - return - - database = get_db() - table = database.execute( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'" - ).fetchone() - if table is None: - init_db() + init_db() def user_count() -> int: diff --git a/nouri/main.py b/nouri/main.py index 411d209..8ff0e75 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import uuid from collections import defaultdict from datetime import date, datetime, timedelta @@ -11,6 +10,7 @@ from flask import ( current_app, flash, g, + jsonify, redirect, render_template, request, @@ -31,12 +31,29 @@ from .db import get_db main_bp = Blueprint("main", __name__) ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} +ACTIVE_STATE_OPTIONS = [ + ("", "Alle aktiven"), + ("home", "Zuhause"), + ("idea", "Merkliste"), +] +KIND_FILTER_OPTIONS = [ + ("", "Alles"), + ("food", "Lebensmittel"), + ("meal", "Mahlzeitenideen"), +] def get_dayparts() -> list: return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall() +def get_daypart_by_id(daypart_id: int): + return get_db().execute( + "SELECT * FROM dayparts WHERE id = ?", + (daypart_id,), + ).fetchone() + + def parse_week_start(raw: str | None) -> date: if raw: try: @@ -48,6 +65,15 @@ def parse_week_start(raw: str | None) -> date: return today - timedelta(days=today.weekday()) +def parse_plan_date(raw: str | None, fallback: date | None = None) -> date: + if raw: + try: + return datetime.strptime(raw, "%Y-%m-%d").date() + except ValueError: + pass + return fallback or date.today() + + def allowed_file(filename: str) -> bool: return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS @@ -108,26 +134,34 @@ def attach_dayparts(items: list) -> list[dict]: return [] database = get_db() - ids = [item["id"] for item in items] - placeholders = ",".join("?" for _ in ids) + item_ids = [item["id"] for item in items] + placeholders = ",".join("?" for _ in item_ids) rows = database.execute( f""" - SELECT item_dayparts.item_id, dayparts.name + SELECT item_dayparts.item_id, dayparts.id, dayparts.slug, dayparts.name FROM item_dayparts JOIN dayparts ON dayparts.id = item_dayparts.daypart_id WHERE item_dayparts.item_id IN ({placeholders}) ORDER BY dayparts.sort_order """, - ids, + item_ids, ).fetchall() grouped = defaultdict(list) for row in rows: - grouped[row["item_id"]].append(row["name"]) + grouped[row["item_id"]].append( + { + "id": row["id"], + "slug": row["slug"], + "name": row["name"], + } + ) enriched = [] for item in items: entry = dict(item) - entry["dayparts"] = grouped.get(item["id"], []) + entry["dayparts_meta"] = grouped.get(item["id"], []) + entry["dayparts"] = [daypart["name"] for daypart in entry["dayparts_meta"]] + entry["primary_daypart_id"] = entry["dayparts_meta"][0]["id"] if entry["dayparts_meta"] else None enriched.append(entry) return enriched @@ -135,6 +169,8 @@ def attach_dayparts(items: list) -> list[dict]: def attach_components(items: list[dict]) -> list[dict]: meal_ids = [item["id"] for item in items if item["kind"] == "meal"] if not meal_ids: + for item in items: + item["components"] = [] return items placeholders = ",".join("?" for _ in meal_ids) @@ -157,21 +193,42 @@ def attach_components(items: list[dict]) -> list[dict]: return items -def fetch_items(kind: str | None = None, availability: str | None = None, include_archived: bool = False): +def fetch_items( + kind: str | None = None, + availability: str | None = None, + include_archived: bool = False, + query: str | None = None, + daypart_id: int | None = None, +): database = get_db() conditions = [] params = [] if kind: - conditions.append("kind = ?") + conditions.append("items.kind = ?") params.append(kind) if availability: - conditions.append("availability_state = ?") + conditions.append("items.availability_state = ?") params.append(availability) elif not include_archived: - conditions.append("availability_state != 'archived'") + conditions.append("items.availability_state != 'archived'") + if query: + conditions.append("LOWER(items.name) LIKE ?") + params.append(f"%{query.lower()}%") + if daypart_id: + conditions.append( + """ + EXISTS ( + SELECT 1 + FROM item_dayparts + WHERE item_dayparts.item_id = items.id + AND item_dayparts.daypart_id = ? + ) + """ + ) + params.append(daypart_id) - where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" rows = database.execute( f""" SELECT items.*, @@ -181,15 +238,73 @@ def fetch_items(kind: str | None = None, availability: str | None = None, includ WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 ) AS is_on_shopping_list FROM items - {where} - ORDER BY LOWER(name) - """ - , params).fetchall() + {where_clause} + ORDER BY + CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END, + LOWER(items.name) + """, + params, + ).fetchall() return attach_components(attach_dayparts(rows)) def fetch_food_options(): - return fetch_items(kind="food", include_archived=False) + return fetch_items(kind="food", include_archived=True) + + +def group_items_by_availability(items: list[dict]) -> list[dict]: + grouped = defaultdict(list) + for item in items: + grouped[item["availability_state"]].append(item) + + ordered_states = ["home", "idea", "archived"] + result = [] + for state in ordered_states: + entries = grouped.get(state, []) + if entries: + result.append( + { + "state": state, + "title": AVAILABILITY_LABELS[state], + "items": entries, + } + ) + return result + + +def extract_item_form_data() -> dict: + return { + "name": request.form.get("name", "").strip(), + "category": request.form.get("category", "").strip(), + "note": request.form.get("note", "").strip(), + "daypart_ids": [int(value) for value in request.form.getlist("daypart_ids")], + "component_ids": [int(value) for value in request.form.getlist("component_ids")], + "quick_food_name": request.form.get("quick_food_name", "").strip(), + "quick_food_category": request.form.get("quick_food_category", "").strip(), + "quick_food_note": request.form.get("quick_food_note", "").strip(), + } + + +def create_quick_food_from_form(form_data: dict) -> int: + database = get_db() + # Inline item creation keeps the meal-idea flow intact instead of forcing a detour. + cursor = database.execute( + """ + INSERT INTO items (kind, name, category, note, created_by, updated_by) + VALUES ('food', ?, ?, ?, ?, ?) + """, + ( + form_data["quick_food_name"], + form_data["quick_food_category"], + form_data["quick_food_note"], + g.user["id"], + g.user["id"], + ), + ) + food_id = cursor.lastrowid + sync_item_dayparts(food_id, form_data["daypart_ids"]) + database.commit() + return food_id def add_to_shopping_list(item_id: int, user_id: int) -> bool: @@ -215,6 +330,14 @@ def add_to_shopping_list(item_id: int, user_id: int) -> bool: return True +def ensure_planned_item_is_shopped(item_id: int, user_id: int) -> bool: + item = get_item(item_id) + if item["availability_state"] == "home": + return False + # Planning something that is not at home should create a gentle follow-up on the shopping list. + return add_to_shopping_list(item_id, user_id) + + def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None: database = get_db() database.execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,)) @@ -239,7 +362,7 @@ def sync_meal_components(meal_id: int, food_ids: list[int]) -> None: def fetch_shopping_entries(): - rows = get_db().execute( + return get_db().execute( """ SELECT shopping_entries.*, items.name AS item_name, @@ -255,42 +378,202 @@ def fetch_shopping_entries(): ORDER BY shopping_entries.added_at DESC """ ).fetchall() - return rows -def fetch_archive_items(): - return fetch_items(availability="archived", include_archived=True) - - -def planner_entries_for_week(week_start: date): - week_end = week_start + timedelta(days=6) +def fetch_plan_entries_for_range(start_date: date, end_date: date): rows = get_db().execute( """ 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.slug AS daypart_slug, + dayparts.sort_order FROM plan_entries JOIN items ON items.id = plan_entries.item_id JOIN dayparts ON dayparts.id = plan_entries.daypart_id WHERE plan_date BETWEEN ? AND ? ORDER BY plan_date, dayparts.sort_order, items.name """, - (week_start.isoformat(), week_end.isoformat()), + (start_date.isoformat(), end_date.isoformat()), ).fetchall() grouped = defaultdict(list) for row in rows: - grouped[(row["plan_date"], row["daypart_id"])].append(row) + grouped[(row["plan_date"], row["daypart_id"])].append(dict(row)) return grouped +def fetch_day_plan_entries(selected_date: date): + return fetch_plan_entries_for_range(selected_date, selected_date) + + +def fetch_week_cards(week_start: date): + days = [week_start + timedelta(days=index) for index in range(7)] + dayparts = get_dayparts() + grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6)) + cards = [] + for current_day in days: + filled_dayparts = [] + planned_count = 0 + preview_items = [] + slots = [] + for daypart in dayparts: + slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), []) + slots.append( + { + "daypart": dict(daypart), + "entries": slot_entries, + } + ) + 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]) + cards.append( + { + "date": current_day, + "filled_dayparts": filled_dayparts, + "planned_count": planned_count, + "preview_items": preview_items[:4], + "slots": slots, + } + ) + return cards + + +def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]: + seen_ids = set() + result = [] + for item in items: + if item["id"] in seen_ids: + continue + seen_ids.add(item["id"]) + result.append(item) + if len(result) >= limit: + break + return result + + +def fetch_recent_plan_items(daypart_id: int, limit: int = 6): + rows = get_db().execute( + """ + SELECT DISTINCT items.id, items.name, items.kind, items.photo_filename, items.availability_state + FROM plan_entries + JOIN items ON items.id = plan_entries.item_id + WHERE plan_entries.daypart_id = ? + ORDER BY plan_entries.created_at DESC + LIMIT ? + """, + (daypart_id, limit * 3), + ).fetchall() + return attach_components(attach_dayparts(rows)) + + +def fetch_plan_candidates(daypart_id: int, query: str | None = None): + params = [daypart_id] + conditions = ["items.availability_state != 'archived'"] + if query: + conditions.append("LOWER(items.name) LIKE ?") + params.append(f"%{query.lower()}%") + + where_clause = f"WHERE {' AND '.join(conditions)}" + rows = get_db().execute( + f""" + SELECT items.*, + EXISTS( + SELECT 1 + FROM item_dayparts + WHERE item_dayparts.item_id = items.id AND item_dayparts.daypart_id = ? + ) AS matches_daypart + FROM items + {where_clause} + ORDER BY + CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END, + matches_daypart DESC, + LOWER(items.name) + """, + params, + ).fetchall() + return attach_components(attach_dayparts(rows)) + + +def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: int | None): + sections = [] + if selected_daypart_id: + selected_daypart = next((daypart for daypart in dayparts if daypart["id"] == selected_daypart_id), None) + matching_items = [item for item in items if any(dp["id"] == selected_daypart_id for dp in item["dayparts_meta"])] + sections.append( + { + "title": selected_daypart["name"] if selected_daypart else "Ausgewählte Tageszeit", + "items": matching_items, + "slug": selected_daypart["slug"] if selected_daypart else "selected", + } + ) + return sections + + for daypart in dayparts: + matching_items = [item for item in items if any(dp["id"] == daypart["id"] for dp in item["dayparts_meta"])] + sections.append( + { + "title": daypart["name"], + "items": matching_items, + "slug": daypart["slug"], + } + ) + + anytime_items = [item for item in items if not item["dayparts_meta"]] + if anytime_items: + sections.append( + { + "title": "Ohne feste Tageszeit", + "items": anytime_items, + "slug": "anytime", + } + ) + return sections + + +def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None): + dayparts = get_dayparts() + day_entries = fetch_day_plan_entries(selected_date) + sections = [] + + for daypart in dayparts: + candidates = fetch_plan_candidates(daypart["id"]) + home_candidates = [item for item in candidates if item["availability_state"] == "home"] + matching_candidates = [ + item for item in candidates + if any(meta["id"] == daypart["id"] for meta in item["dayparts_meta"]) + ] + recent_candidates = fetch_recent_plan_items(daypart["id"]) + quick_items = dedupe_items(home_candidates + recent_candidates + matching_candidates, limit=6) + sections.append( + { + "daypart": daypart, + "entries": day_entries.get((selected_date.isoformat(), daypart["id"]), []), + "candidates": candidates, + "quick_items": quick_items, + "selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None, + "is_open": selected_daypart_id == daypart["id"], + "summary_items": [entry["item_name"] for entry in day_entries.get((selected_date.isoformat(), daypart["id"]), [])][:2], + } + ) + return sections + + @main_bp.get("/") @login_required def dashboard(): database = get_db() - today = date.today().isoformat() + today = date.today() home_count = database.execute( "SELECT COUNT(*) AS count FROM items WHERE availability_state = 'home'" ).fetchone()["count"] @@ -305,6 +588,7 @@ def dashboard(): SELECT plan_entries.id, items.name AS item_name, items.kind AS item_kind, + items.availability_state, dayparts.name AS daypart_name FROM plan_entries JOIN items ON items.id = plan_entries.item_id @@ -312,8 +596,9 @@ def dashboard(): WHERE plan_entries.plan_date = ? ORDER BY dayparts.sort_order, items.name """, - (today,), + (today.isoformat(),), ).fetchall() + week_cards = fetch_week_cards(today - timedelta(days=today.weekday())) home_items = fetch_items(availability="home") return render_template( "dashboard.html", @@ -323,6 +608,7 @@ def dashboard(): today_entries=today_entries, home_items=home_items[:8], today=today, + week_cards=week_cards[:3], ) @@ -331,12 +617,29 @@ def dashboard(): def item_list(kind: str): if kind not in ITEM_KIND_LABELS: return redirect(url_for("main.dashboard")) - items = fetch_items(kind=kind) + + query = request.args.get("q", "").strip() + state = request.args.get("state", "").strip() + raw_daypart_id = request.args.get("daypart_id", "").strip() + daypart_id = int(raw_daypart_id) if raw_daypart_id.isdigit() else None + + items = fetch_items( + kind=kind, + availability=state or None, + query=query or None, + daypart_id=daypart_id, + ) return render_template( "items/list.html", kind=kind, items=items, availability_labels=AVAILABILITY_LABELS, + query=query, + selected_state=state, + selected_daypart_id=daypart_id, + dayparts=get_dayparts(), + state_options=ACTIVE_STATE_OPTIONS, + today=date.today(), ) @@ -349,35 +652,55 @@ def item_create(kind: str): database = get_db() dayparts = get_dayparts() foods = fetch_food_options() + food_groups = group_items_by_availability(foods) form_data = { "name": "", "category": "", "note": "", "daypart_ids": [], "component_ids": [], + "quick_food_name": "", + "quick_food_category": "", + "quick_food_note": "", } if request.method == "POST": - name = request.form.get("name", "").strip() - category = request.form.get("category", "").strip() - note = request.form.get("note", "").strip() - daypart_ids = [int(value) for value in request.form.getlist("daypart_ids")] - component_ids = [int(value) for value in request.form.getlist("component_ids")] - form_data.update( - { - "name": name, - "category": category, - "note": note, - "daypart_ids": daypart_ids, - "component_ids": component_ids, - } - ) + form_action = request.form.get("form_action", "save_item") + form_data.update(extract_item_form_data()) + name = form_data["name"] + category = form_data["category"] + note = form_data["note"] + daypart_ids = form_data["daypart_ids"] + component_ids = form_data["component_ids"] + + if kind == "meal" and form_action == "quick_add_food": + if not form_data["quick_food_name"]: + flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error") + else: + new_food_id = create_quick_food_from_form(form_data) + if new_food_id not in form_data["component_ids"]: + form_data["component_ids"].append(new_food_id) + form_data["component_ids"] = sorted(form_data["component_ids"]) + form_data["quick_food_name"] = "" + form_data["quick_food_category"] = "" + form_data["quick_food_note"] = "" + foods = fetch_food_options() + food_groups = group_items_by_availability(foods) + flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success") + return render_template( + "items/form.html", + kind=kind, + item=None, + dayparts=dayparts, + foods=foods, + food_groups=food_groups, + categories=CATEGORIES, + form_data=form_data, + ) error = None if not name: error = "Bitte einen Namen eintragen." - elif kind == "meal" and not component_ids: - error = "Bitte mindestens ein Lebensmittel fuer die Mahlzeitenidee waehlen." photo_filename = None if error is None: @@ -410,6 +733,7 @@ def item_create(kind: str): item=None, dayparts=dayparts, foods=foods, + food_groups=food_groups, categories=CATEGORIES, form_data=form_data, ) @@ -423,35 +747,55 @@ def item_edit(item_id: int): kind = item["kind"] dayparts = get_dayparts() foods = fetch_food_options() + food_groups = group_items_by_availability(foods) form_data = { "name": item["name"], "category": item["category"] or "", "note": item["note"] or "", "daypart_ids": get_item_daypart_ids(item_id), "component_ids": get_meal_component_ids(item_id) if kind == "meal" else [], + "quick_food_name": "", + "quick_food_category": "", + "quick_food_note": "", } if request.method == "POST": - name = request.form.get("name", "").strip() - category = request.form.get("category", "").strip() - note = request.form.get("note", "").strip() - daypart_ids = [int(value) for value in request.form.getlist("daypart_ids")] - component_ids = [int(value) for value in request.form.getlist("component_ids")] - form_data.update( - { - "name": name, - "category": category, - "note": note, - "daypart_ids": daypart_ids, - "component_ids": component_ids, - } - ) + form_action = request.form.get("form_action", "save_item") + form_data.update(extract_item_form_data()) + name = form_data["name"] + category = form_data["category"] + note = form_data["note"] + daypart_ids = form_data["daypart_ids"] + component_ids = form_data["component_ids"] + + if kind == "meal" and form_action == "quick_add_food": + if not form_data["quick_food_name"]: + flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error") + else: + new_food_id = create_quick_food_from_form(form_data) + if new_food_id not in form_data["component_ids"]: + form_data["component_ids"].append(new_food_id) + form_data["component_ids"] = sorted(form_data["component_ids"]) + form_data["quick_food_name"] = "" + form_data["quick_food_category"] = "" + form_data["quick_food_note"] = "" + foods = fetch_food_options() + food_groups = group_items_by_availability(foods) + flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success") + return render_template( + "items/form.html", + kind=kind, + item=item, + dayparts=dayparts, + foods=foods, + food_groups=food_groups, + categories=CATEGORIES, + form_data=form_data, + ) error = None if not name: error = "Bitte einen Namen eintragen." - elif kind == "meal" and not component_ids: - error = "Bitte mindestens ein Lebensmittel fuer die Mahlzeitenidee waehlen." photo_filename = item["photo_filename"] if error is None: @@ -484,6 +828,7 @@ def item_edit(item_id: int): item=item, dayparts=dayparts, foods=foods, + food_groups=food_groups, categories=CATEGORIES, form_data=form_data, ) @@ -533,7 +878,7 @@ def item_archive(item_id: int): (g.user["id"], item_id), ) database.commit() - flash(f"{item['name']} liegt jetzt im Archiv und bleibt spaeter leicht wiederfindbar.", "info") + flash(f"{item['name']} liegt jetzt im Archiv und bleibt später leicht wiederfindbar.", "info") return redirect(request.referrer or url_for("main.archive_view")) @@ -563,7 +908,7 @@ def shopping_list(): if request.method == "POST": selected_item_id = request.form.get("item_id", "").strip() if not selected_item_id: - flash("Bitte zuerst etwas auswaehlen.", "error") + flash("Bitte zuerst etwas auswählen.", "error") else: item = get_item(int(selected_item_id)) added = add_to_shopping_list(item["id"], g.user["id"]) @@ -636,89 +981,162 @@ def shopping_remove(entry_id: int): @main_bp.get("/home") @login_required def home_view(): - items = fetch_items(availability="home") - grouped = defaultdict(list) - for item in items: - key = item["dayparts"][0] if item["dayparts"] else "Ohne feste Tageszeit" - grouped[key].append(item) - return render_template("home/list.html", grouped=grouped) + query = request.args.get("q", "").strip() + raw_daypart_id = request.args.get("daypart_id", "").strip() + daypart_id = int(raw_daypart_id) if raw_daypart_id.isdigit() else None + dayparts = get_dayparts() + items = fetch_items( + availability="home", + query=query or None, + daypart_id=daypart_id, + ) + sections = build_home_sections(items, dayparts, daypart_id) + return render_template( + "home/list.html", + sections=sections, + query=query, + dayparts=dayparts, + selected_daypart_id=daypart_id, + today=date.today(), + ) @main_bp.get("/archive") @login_required def archive_view(): - items = fetch_archive_items() - return render_template("archive/list.html", items=items) + query = request.args.get("q", "").strip() + selected_kind = request.args.get("kind", "").strip() + kind = selected_kind if selected_kind in ITEM_KIND_LABELS else None + items = fetch_items( + kind=kind, + availability="archived", + include_archived=True, + query=query or None, + ) + return render_template( + "archive/list.html", + items=items, + query=query, + selected_kind=selected_kind, + kind_options=KIND_FILTER_OPTIONS, + ) -@main_bp.route("/planner", methods=("GET", "POST")) +@main_bp.get("/planner") @login_required def planner(): - database = get_db() - week_start = parse_week_start(request.values.get("week")) + week_start = parse_week_start(request.args.get("week")) + return render_template( + "planner/week.html", + week_start=week_start, + week_end=week_start + timedelta(days=6), + prev_week=week_start - timedelta(days=7), + next_week=week_start + timedelta(days=7), + week_cards=fetch_week_cards(week_start), + today=date.today(), + ) + + +@main_bp.route("/planner/day", methods=("GET", "POST")) +@login_required +def planner_day(): + selected_date = parse_plan_date(request.values.get("date")) if request.method == "POST": - try: - selected_date = datetime.strptime(request.form.get("plan_date", ""), "%Y-%m-%d").date() - except ValueError: - selected_date = None - - item_id = request.form.get("item_id", "").strip() - daypart_id = request.form.get("daypart_id", "").strip() + item_id_raw = request.form.get("item_id", "").strip() + daypart_id_raw = request.form.get("daypart_id", "").strip() note = request.form.get("note", "").strip() + selected_date = parse_plan_date(request.form.get("plan_date")) error = None - if selected_date is None: - error = "Bitte einen gueltigen Tag auswaehlen." - elif not item_id: - error = "Bitte etwas fuer den Plan waehlen." - elif not daypart_id: - error = "Bitte eine Tageszeit waehlen." + if not item_id_raw: + error = "Bitte etwas für den Tagesplan auswählen." + elif not daypart_id_raw: + error = "Bitte eine Tageszeit auswählen." if error is None: - database.execute( + item_id = int(item_id_raw) + daypart_id = int(daypart_id_raw) + get_db().execute( """ INSERT INTO plan_entries (plan_date, daypart_id, item_id, note, created_by) VALUES (?, ?, ?, ?, ?) """, - (selected_date.isoformat(), int(daypart_id), int(item_id), note, g.user["id"]), + (selected_date.isoformat(), daypart_id, item_id, note, g.user["id"]), + ) + get_db().commit() + if ensure_planned_item_is_shopped(item_id, g.user["id"]): + flash("Der Eintrag ist noch nicht zuhause und wurde zusätzlich auf die Einkaufsliste gesetzt.", "info") + flash("Der Eintrag wurde in den Tagesplan gelegt.", "success") + return redirect( + f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}" ) - database.commit() - flash("Der Eintrag wurde in den Wochenplan gelegt.", "success") - else: - flash(error, "error") - return redirect(url_for("main.planner", week=week_start.isoformat())) + flash(error, "error") - days = [week_start + timedelta(days=index) for index in range(7)] - dayparts = get_dayparts() - entries = planner_entries_for_week(week_start) - selectable_items = database.execute( - """ - SELECT id, name, kind, availability_state - FROM items - WHERE availability_state != 'archived' - ORDER BY CASE availability_state WHEN 'home' THEN 0 ELSE 1 END, LOWER(name) - """ - ).fetchall() + selected_item_raw = request.args.get("item_id", "").strip() + selected_daypart_raw = request.args.get("daypart_id", "").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 + sections = build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id) return render_template( - "planner/week.html", - week_start=week_start, - prev_week=week_start - timedelta(days=7), - next_week=week_start + timedelta(days=7), - days=days, - dayparts=dayparts, - entries=entries, - selectable_items=selectable_items, + "planner/day.html", + selected_date=selected_date, + previous_day=selected_date - timedelta(days=1), + next_day=selected_date + timedelta(days=1), + sections=sections, + today=date.today(), ) @main_bp.post("/planner//remove") @login_required def planner_remove(entry_id: int): - database = get_db() - week = request.args.get("week") - database.execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,)) - database.commit() + selected_date = request.args.get("date", "") + get_db().execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,)) + get_db().commit() flash("Der Planeintrag wurde entfernt.", "info") - return redirect(url_for("main.planner", week=week)) + if selected_date: + return redirect(url_for("main.planner_day", date=selected_date)) + return redirect(url_for("main.planner")) + + +@main_bp.post("/planner//move") +@login_required +def planner_move(entry_id: int): + target_date = parse_plan_date(request.form.get("target_date")) + target_daypart_raw = request.form.get("target_daypart_id", "").strip() + + if not target_daypart_raw.isdigit(): + return jsonify({"ok": False, "error": "Ungültige Tageszeit"}), 400 + + database = get_db() + entry = database.execute( + "SELECT * FROM plan_entries WHERE id = ?", + (entry_id,), + ).fetchone() + if entry is None: + return jsonify({"ok": False, "error": "Eintrag nicht gefunden"}), 404 + + target_daypart_id = int(target_daypart_raw) + database.execute( + """ + UPDATE plan_entries + SET plan_date = ?, daypart_id = ? + WHERE id = ? + """, + (target_date.isoformat(), target_daypart_id, entry_id), + ) + database.commit() + + # Reuse the same shopping safeguard as the day planner after drag-and-drop moves. + was_added_to_shopping = ensure_planned_item_is_shopped(entry["item_id"], g.user["id"]) + if was_added_to_shopping: + flash("Der verschobene Eintrag ist noch nicht zuhause und wurde auf die Einkaufsliste gesetzt.", "info") + return jsonify( + { + "ok": True, + "added_to_shopping": was_added_to_shopping, + "redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()), + } + ) diff --git a/nouri/schema.sql b/nouri/schema.sql index cfd359b..a653e93 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -76,3 +76,15 @@ CREATE TABLE IF NOT EXISTS plan_entries ( FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); + +CREATE INDEX IF NOT EXISTS idx_items_kind_name +ON items (kind, name); + +CREATE INDEX IF NOT EXISTS idx_items_availability_name +ON items (availability_state, name); + +CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item +ON item_dayparts (daypart_id, item_id); + +CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart +ON plan_entries (plan_date, daypart_id); diff --git a/nouri/static/brand/favicon.svg b/nouri/static/brand/favicon.svg new file mode 100644 index 0000000..fe1b8a0 --- /dev/null +++ b/nouri/static/brand/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/nouri/static/brand/nouri-icon.svg b/nouri/static/brand/nouri-icon.svg new file mode 100644 index 0000000..662d605 --- /dev/null +++ b/nouri/static/brand/nouri-icon.svg @@ -0,0 +1,21 @@ + + Nouri + + + + + + + + + + + + + + + + + + + diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index 20f421c..859be49 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -1,37 +1,43 @@ :root { color-scheme: light; - --bg: #f5f1e8; - --bg-elevated: rgba(255, 252, 246, 0.9); - --surface: #fffaf2; - --surface-strong: #ffffff; - --surface-soft: #efe7d7; - --line: rgba(74, 78, 72, 0.12); - --text: #243028; - --muted: #66736a; - --accent: #6a8b78; - --accent-strong: #476654; - --accent-soft: rgba(106, 139, 120, 0.12); - --warning-soft: rgba(196, 136, 92, 0.16); - --shadow: 0 18px 40px rgba(44, 56, 46, 0.08); - --radius: 20px; + --bg: #fff5ee; + --bg-elevated: rgba(255, 249, 244, 0.78); + --surface: rgba(255, 255, 255, 0.82); + --surface-strong: #fffdfa; + --surface-soft: #fff0e3; + --line: rgba(133, 113, 95, 0.12); + --text: #342e2d; + --muted: #7c716d; + --accent: #f0a46c; + --accent-strong: #dd8d52; + --accent-soft: rgba(240, 164, 108, 0.18); + --mint-soft: rgba(174, 214, 193, 0.24); + --peach-soft: rgba(255, 210, 179, 0.24); + --sky-soft: rgba(194, 213, 235, 0.2); + --rose-soft: rgba(237, 196, 205, 0.22); + --shadow: 0 20px 50px rgba(125, 92, 68, 0.10); + --radius: 22px; --font-body: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif; --font-heading: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; } [data-theme="dark"] { color-scheme: dark; - --bg: #1b211d; - --bg-elevated: rgba(27, 33, 29, 0.92); - --surface: #222925; - --surface-strong: #29312c; - --surface-soft: #323b36; - --line: rgba(224, 229, 223, 0.1); - --text: #edf1ea; - --muted: #b6c0b6; - --accent: #9dbf9d; - --accent-strong: #b8d5b1; - --accent-soft: rgba(157, 191, 157, 0.15); - --warning-soft: rgba(201, 148, 108, 0.22); + --bg: #201d1d; + --bg-elevated: rgba(32, 29, 29, 0.82); + --surface: rgba(45, 39, 39, 0.82); + --surface-strong: #383131; + --surface-soft: #433a39; + --line: rgba(226, 232, 225, 0.1); + --text: #f4efec; + --muted: #cabeb7; + --accent: #f2b07d; + --accent-strong: #ffc190; + --accent-soft: rgba(242, 176, 125, 0.18); + --mint-soft: rgba(155, 198, 175, 0.20); + --peach-soft: rgba(224, 161, 128, 0.18); + --sky-soft: rgba(146, 171, 201, 0.18); + --rose-soft: rgba(189, 133, 145, 0.20); --shadow: 0 18px 40px rgba(0, 0, 0, 0.28); } @@ -48,9 +54,10 @@ body { font-family: var(--font-body); color: var(--text); background: - radial-gradient(circle at top left, rgba(178, 197, 168, 0.28), transparent 26rem), - radial-gradient(circle at top right, rgba(238, 210, 177, 0.25), transparent 28rem), - linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 84%, #000 16%)); + radial-gradient(circle at top left, rgba(255, 205, 174, 0.42), transparent 24rem), + radial-gradient(circle at 90% 8%, rgba(190, 226, 203, 0.34), transparent 24rem), + radial-gradient(circle at 40% 100%, rgba(255, 228, 205, 0.32), transparent 28rem), + linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 92%, #f6decb 8%)); } a { @@ -76,7 +83,7 @@ button, align-items: center; justify-content: center; gap: 0.45rem; - padding: 0.8rem 1.1rem; + padding: 0.82rem 1.1rem; border: 1px solid transparent; border-radius: 999px; background: var(--accent); @@ -106,7 +113,7 @@ button.secondary:hover, } .page-shell { - width: min(1200px, calc(100% - 2rem)); + width: min(1320px, calc(100% - 2rem)); margin: 1rem auto 2rem; } @@ -122,9 +129,9 @@ button.secondary:hover, margin-bottom: 1.25rem; background: var(--bg-elevated); border: 1px solid var(--line); - border-radius: 24px; + border-radius: 28px; box-shadow: var(--shadow); - backdrop-filter: blur(18px); + backdrop-filter: blur(26px) saturate(1.2); } .brand { @@ -146,20 +153,84 @@ h1, h2, h3, .planner-label { } .brand-mark { - width: 2.5rem; - height: 2.5rem; + width: 2.7rem; + height: 2.7rem; display: grid; place-items: center; - border-radius: 0.9rem; - background: linear-gradient(135deg, var(--accent), #d1b48f); - color: white; - font-weight: 700; + border-radius: 1rem; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 236, 219, 0.92)); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.8); +} + +.brand-mark img { + width: 100%; + height: 100%; +} + +.nav-link-inner { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.ui-icon { + width: 1rem; + height: 1rem; + display: inline-block; + background: currentColor; + flex: 0 0 auto; + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +.icon-house { + -webkit-mask-image: url("../icons/fa/house.svg"); + mask-image: url("../icons/fa/house.svg"); +} + +.icon-utensils { + -webkit-mask-image: url("../icons/fa/utensils.svg"); + mask-image: url("../icons/fa/utensils.svg"); +} + +.icon-bowl-food { + -webkit-mask-image: url("../icons/fa/bowl-food.svg"); + mask-image: url("../icons/fa/bowl-food.svg"); +} + +.icon-cart-shopping { + -webkit-mask-image: url("../icons/fa/cart-shopping.svg"); + mask-image: url("../icons/fa/cart-shopping.svg"); +} + +.icon-calendar { + -webkit-mask-image: url("../icons/fa/calendar.svg"); + mask-image: url("../icons/fa/calendar.svg"); +} + +.icon-calendar-days { + -webkit-mask-image: url("../icons/fa/calendar-days.svg"); + mask-image: url("../icons/fa/calendar-days.svg"); +} + +.icon-archive { + -webkit-mask-image: url("../icons/fa/archive.svg"); + mask-image: url("../icons/fa/archive.svg"); +} + +.icon-sparkles { + -webkit-mask-image: url("../icons/fa/sparkles.svg"); + mask-image: url("../icons/fa/sparkles.svg"); } .site-nav { display: flex; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.45rem; justify-content: center; } @@ -173,6 +244,7 @@ h1, h2, h3, .planner-label { .site-nav a:hover { background: var(--accent-soft); color: var(--text); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.4); } .header-actions { @@ -193,17 +265,21 @@ h1, h2, h3, .planner-label { .stat-card, .item-card, .list-row, -.planner-entry { +.planner-entry, +.week-card, +.week-mini-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); + backdrop-filter: blur(18px) saturate(1.12); } .hero, .page-intro, .panel, -.auth-card { +.auth-card, +.week-card { padding: 1.35rem; } @@ -213,8 +289,8 @@ h1, h2, h3, .planner-label { gap: 1rem; align-items: end; background: - linear-gradient(135deg, rgba(255, 255, 255, 0.35), transparent 45%), - linear-gradient(180deg, var(--surface), var(--surface-strong)); + linear-gradient(135deg, rgba(255, 255, 255, 0.52), transparent 45%), + linear-gradient(180deg, color-mix(in srgb, var(--surface) 86%, #fff 14%), color-mix(in srgb, var(--surface) 80%, #ffe5d2 20%)); } .eyebrow { @@ -260,7 +336,9 @@ h3 { .stats-grid, .two-column, .card-grid, -.mini-card-grid { +.mini-card-grid, +.week-mini-grid, +.week-overview-grid { display: grid; gap: 1rem; } @@ -270,11 +348,21 @@ h3 { } .two-column { - grid-template-columns: 1.1fr 0.9fr; + grid-template-columns: 1.05fr 0.95fr; +} + +.week-mini-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.week-overview-grid { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); } .stat-card { padding: 1.15rem 1.2rem; + background: + linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--surface) 90%, #fff 10%)); } .stat-card span { @@ -295,7 +383,9 @@ h3 { .row-actions, .hero-actions, .form-actions, -.week-nav { +.week-nav, +.week-card-head, +.planner-entry-top { display: flex; gap: 0.85rem; justify-content: space-between; @@ -328,13 +418,45 @@ h3 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } -.mini-card { +.mini-card, +.week-mini-card { border-radius: 18px; background: var(--surface-strong); border: 1px solid var(--line); padding: 1rem; } +.component-group { + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.4); + border: 1px solid var(--line); +} + +.quick-food-panel { + margin-top: 1rem; + padding: 1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.5); + border: 1px solid var(--line); +} + +.quick-food-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.8rem; + align-items: end; +} + +.quick-food-grid .wide { + grid-column: span 2; +} + +.week-mini-card { + display: grid; + gap: 0.4rem; +} + .chip-row { display: flex; flex-wrap: wrap; @@ -354,19 +476,19 @@ h3 { } .status-home { - background: rgba(96, 147, 114, 0.18); + background: rgba(121, 176, 144, 0.22); } .status-archived { - background: var(--warning-soft); + background: var(--peach-soft); } .status-idea { - background: rgba(130, 146, 151, 0.16); + background: var(--sky-soft); } .card-grid { - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); } .item-card { @@ -434,12 +556,25 @@ h3 { } .stack-form label, -.planner-form label { +.planner-entry-form label, +.filter-form label { display: grid; gap: 0.5rem; color: var(--muted); } +.inline-check { + display: inline-flex !important; + align-items: center; + gap: 0.7rem; + color: var(--text) !important; +} + +.inline-check input[type="checkbox"] { + width: 1.05rem; + height: 1.05rem; +} + input[type="text"], input[type="password"], input[type="date"], @@ -493,71 +628,202 @@ legend { } .inline-form, -.planner-form { +.planner-entry-form, +.filter-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.8rem; align-items: end; } -.planner-form .wide { +.planner-entry-form, +.filter-form { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.filter-form .wide, +.planner-entry-form .wide { grid-column: span 2; } +.filter-actions { + display: flex; + gap: 0.65rem; + align-items: center; +} + .list-row { padding: 1rem 1.1rem; } .row-actions { justify-content: end; + flex-wrap: wrap; } -.stack-sections { +.stack-sections, +.planner-day-stack, +.planner-entry-list { display: grid; gap: 1rem; } -.planner-grid { - display: grid; - gap: 1rem; -} - -.planner-row { - display: grid; - grid-template-columns: 180px repeat(7, minmax(0, 1fr)); - gap: 0.75rem; - align-items: start; -} - -.planner-label { - padding: 1rem; - border-radius: 18px; - background: var(--surface-soft); - border: 1px solid var(--line); -} - -.planner-cell { - min-height: 150px; - padding: 0.8rem; - border-radius: 18px; +.day-tile { + border-radius: 24px; background: var(--surface); border: 1px solid var(--line); + box-shadow: var(--shadow); + overflow: hidden; } -.planner-date { - margin-bottom: 0.7rem; - font-size: 0.9rem; +.day-tile > summary::-webkit-details-marker { + display: none; +} + +.day-tile summary { + list-style: none; +} + +.day-tile-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.2rem 1.25rem; + cursor: pointer; +} + +.day-tile-summary-main { + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; +} + +.day-tile-icon { + width: 2.8rem; + height: 2.8rem; + display: grid; + place-items: center; + border-radius: 1rem; + background: linear-gradient(145deg, rgba(255,255,255,0.92), var(--peach-soft)); + color: var(--accent-strong); +} + +.day-tile-icon .ui-icon { + width: 1.15rem; + height: 1.15rem; +} + +.day-tile-body { + padding: 0 1.25rem 1.25rem; + border-top: 1px solid var(--line); +} + +.quick-add-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.quick-add-row form { + margin: 0; +} + +.quick-add-button { + display: grid; + justify-items: start; + padding: 0.9rem 1rem; + min-width: 180px; + border-radius: 18px; + background: color-mix(in srgb, var(--surface-strong) 76%, #fff 24%); + color: var(--text); + border: 1px solid var(--line); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.55); +} + +.quick-add-button:hover { + background: var(--accent-soft); +} + +.quick-add-button small { color: var(--muted); } -.planner-entry-stack { - display: grid; - gap: 0.55rem; +.planner-entry { + padding: 0.9rem 1rem; + border-radius: 18px; } -.planner-entry { - padding: 0.75rem; +.planner-entry-list .planner-entry { + background: color-mix(in srgb, var(--surface) 88%, #fff 12%); +} + +.week-card-count { + font-size: 1.25rem; + font-family: var(--font-heading); + margin: 0.8rem 0 0.2rem; +} + +.week-card-actions { + margin-top: 1rem; +} + +.week-slot-stack { + display: grid; + gap: 0.75rem; + margin-top: 1rem; +} + +.week-slot { + padding: 0.85rem; + border-radius: 18px; + background: color-mix(in srgb, var(--surface-strong) 80%, #fff 20%); + border: 1px solid var(--line); + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.week-slot.is-drag-over { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 60%, var(--line) 40%); + transform: translateY(-1px); +} + +.week-slot-head { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; + margin-bottom: 0.5rem; +} + +.week-entry-stack { + display: grid; + gap: 0.5rem; +} + +.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); + cursor: grab; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.65); +} + +.plan-chip:active { + cursor: grabbing; +} + +.plan-chip.is-dragging { + opacity: 0.55; + transform: scale(0.98); +} + +.plan-chip small, +.week-slot-empty { + color: var(--muted); } .flash-stack { @@ -573,26 +839,27 @@ legend { } .flash-success { - background: rgba(111, 161, 122, 0.18); + background: rgba(121, 176, 144, 0.2); } .flash-error { - background: rgba(195, 111, 98, 0.18); + background: rgba(210, 125, 115, 0.18); } .flash-info { - background: rgba(125, 150, 164, 0.18); + background: rgba(147, 179, 205, 0.18); } .theme-toggle { min-width: 5rem; } -@media (max-width: 980px) { +@media (max-width: 1080px) { .site-header, .hero, .page-intro, - .panel-head { + .panel-head, + .week-card-head { grid-template-columns: 1fr; flex-direction: column; align-items: start; @@ -604,20 +871,16 @@ legend { .stats-grid, .two-column, - .planner-row, .inline-form, - .planner-form { + .planner-entry-form, + .filter-form { grid-template-columns: 1fr; } - .planner-form .wide { + .filter-form .wide, + .planner-entry-form .wide { grid-column: auto; } - - .planner-label { - position: sticky; - left: 0; - } } @media (max-width: 720px) { @@ -633,11 +896,18 @@ legend { .header-actions, .item-card, .list-row, - .row-actions { + .row-actions, + .quick-add-row, + .filter-actions { justify-content: start; } .item-card { grid-template-columns: 1fr; } + + .week-nav { + align-items: start; + flex-wrap: wrap; + } } diff --git a/nouri/static/icons/fa/archive.svg b/nouri/static/icons/fa/archive.svg new file mode 100644 index 0000000..1f431c2 --- /dev/null +++ b/nouri/static/icons/fa/archive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/bowl-food.svg b/nouri/static/icons/fa/bowl-food.svg new file mode 100644 index 0000000..d7d5e55 --- /dev/null +++ b/nouri/static/icons/fa/bowl-food.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/calendar-days.svg b/nouri/static/icons/fa/calendar-days.svg new file mode 100644 index 0000000..87c7139 --- /dev/null +++ b/nouri/static/icons/fa/calendar-days.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/calendar.svg b/nouri/static/icons/fa/calendar.svg new file mode 100644 index 0000000..945baed --- /dev/null +++ b/nouri/static/icons/fa/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/cart-shopping.svg b/nouri/static/icons/fa/cart-shopping.svg new file mode 100644 index 0000000..333093a --- /dev/null +++ b/nouri/static/icons/fa/cart-shopping.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/house.svg b/nouri/static/icons/fa/house.svg new file mode 100644 index 0000000..f789ed9 --- /dev/null +++ b/nouri/static/icons/fa/house.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/sparkles.svg b/nouri/static/icons/fa/sparkles.svg new file mode 100644 index 0000000..43dd28d --- /dev/null +++ b/nouri/static/icons/fa/sparkles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/icons/fa/utensils.svg b/nouri/static/icons/fa/utensils.svg new file mode 100644 index 0000000..314a770 --- /dev/null +++ b/nouri/static/icons/fa/utensils.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nouri/static/js/planner.js b/nouri/static/js/planner.js new file mode 100644 index 0000000..84e19ef --- /dev/null +++ b/nouri/static/js/planner.js @@ -0,0 +1,80 @@ +(() => { + const getCsrfToken = () => { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute("content") : ""; + }; + + const initWeekDragAndDrop = () => { + const board = document.querySelector(".week-board"); + if (!board) return; + + let draggedEntry = null; + + board.querySelectorAll(".draggable-plan-entry").forEach((entry) => { + entry.addEventListener("dragstart", () => { + draggedEntry = entry; + entry.classList.add("is-dragging"); + }); + + entry.addEventListener("dragend", () => { + entry.classList.remove("is-dragging"); + draggedEntry = null; + board.querySelectorAll(".drop-slot").forEach((slot) => slot.classList.remove("is-drag-over")); + }); + }); + + board.querySelectorAll(".drop-slot").forEach((slot) => { + slot.addEventListener("dragover", (event) => { + event.preventDefault(); + if (!draggedEntry) return; + slot.classList.add("is-drag-over"); + }); + + slot.addEventListener("dragleave", () => { + slot.classList.remove("is-drag-over"); + }); + + slot.addEventListener("drop", async (event) => { + event.preventDefault(); + slot.classList.remove("is-drag-over"); + if (!draggedEntry) return; + + // Keep DnD lightweight: move on the server, then refresh into the canonical rendered state. + const moveUrl = draggedEntry.dataset.moveUrl; + const payload = new URLSearchParams({ + csrf_token: getCsrfToken(), + target_date: slot.dataset.targetDate, + target_daypart_id: slot.dataset.targetDaypartId, + }); + + try { + const response = await fetch(moveUrl, { + 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("move failed"); + } + + const result = await response.json(); + if (result.redirect_url) { + window.location.href = result.redirect_url; + } else { + window.location.reload(); + } + } catch (_error) { + window.location.reload(); + } + }); + }); + }; + + document.addEventListener("DOMContentLoaded", () => { + initWeekDragAndDrop(); + }); +})(); diff --git a/nouri/templates/archive/list.html b/nouri/templates/archive/list.html index df4c0ee..542b8bc 100644 --- a/nouri/templates/archive/list.html +++ b/nouri/templates/archive/list.html @@ -4,11 +4,32 @@

Archiv

-

Fruehere Ideen bleiben greifbar

+

Frühere Ideen bleiben greifbar

Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.

+
+
+ + +
+ + Zurücksetzen +
+
+
+ {% if items %}
{% for item in items %} @@ -42,6 +63,7 @@ {{ csrf_input() }} + Im Tagesplan öffnen
{{ csrf_input() }} @@ -52,8 +74,8 @@
{% else %}
-

Das Archiv ist noch leer

-

Sobald etwas als verbraucht markiert wird, bleibt es hier als spaetere Erinnerung erhalten.

+

Keine passenden Archiv-Einträge

+

Mit einer kurzen Suche findest du vertraute Dinge meist schnell wieder.

{% endif %} {% endblock %} diff --git a/nouri/templates/auth/login.html b/nouri/templates/auth/login.html index 7e68658..54efe0e 100644 --- a/nouri/templates/auth/login.html +++ b/nouri/templates/auth/login.html @@ -3,7 +3,7 @@ {% block content %}
-

Willkommen zurueck

+

Willkommen zurück

Ruhig wieder einsteigen

Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.

@@ -17,6 +17,10 @@ Passwort +
diff --git a/nouri/templates/auth/setup.html b/nouri/templates/auth/setup.html index 0c350b5..e2b9199 100644 --- a/nouri/templates/auth/setup.html +++ b/nouri/templates/auth/setup.html @@ -5,7 +5,7 @@

Erster Start

Den ersten Haushalt-Zugang anlegen

-

Danach koennt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.

+

Danach könnt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.

{{ csrf_input() }} diff --git a/nouri/templates/base.html b/nouri/templates/base.html index 3007228..0b04680 100644 --- a/nouri/templates/base.html +++ b/nouri/templates/base.html @@ -4,28 +4,34 @@ {% block title %}Nouri{% endblock %} + + +
@@ -17,7 +17,7 @@
Zuhause {{ home_count }} - sichtbare Eintraege + sichtbare Einträge
Einkaufsliste @@ -35,19 +35,24 @@

Heute im Plan

- Wochenplan oeffnen + Zum Tagesplan
{% if today_entries %}
    {% for entry in today_entries %}
  • - {{ entry.daypart_name }} - {{ entry.item_name }} +
    + {{ entry.daypart_name }} + {{ entry.item_name }} +
    + {% if entry.availability_state == 'home' %} + zuhause + {% endif %}
  • {% endfor %}
{% else %} -

Fuer heute ist noch nichts fest eingeplant. Das ist vollkommen okay.

+

Für heute ist noch nichts fest eingeplant. Das ist vollkommen okay.

{% endif %}
@@ -79,4 +84,25 @@ {% endif %}
+ +
+
+

Nächste Tage

+ Wochenansicht öffnen +
+ +
{% endblock %} diff --git a/nouri/templates/home/list.html b/nouri/templates/home/list.html index bab82ea..4154cd8 100644 --- a/nouri/templates/home/list.html +++ b/nouri/templates/home/list.html @@ -5,20 +5,42 @@

Zuhause

Was aktuell da ist

-

Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.

+

Sichtbar, ruhig und besser nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.

-{% if grouped %} +
+ + + +
+ + Zurücksetzen +
+ +
+ +{% if sections %}
- {% for title, items in grouped.items() %} + {% for section in sections if section["items"] %}
-

{{ title }}

- {{ items|length }} Eintraege +

{{ section["title"] }}

+ {{ section["items"]|length }} Einträge
- {% for item in items %} + {% for item in section["items"] %}
{% if item.photo_filename %} @@ -35,6 +57,7 @@ {% endif %}
+ Im Tagesplan öffnen
{{ csrf_input() }} diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html index ac7eb97..d018eeb 100644 --- a/nouri/templates/items/form.html +++ b/nouri/templates/items/form.html @@ -58,20 +58,56 @@ {% if kind == 'meal' %}
Bestandteile der Mahlzeitenidee -
- {% for food in foods %} -
{% endif %}
- - Zurueck + + Zurück
diff --git a/nouri/templates/items/list.html b/nouri/templates/items/list.html index 1595462..2e2fe48 100644 --- a/nouri/templates/items/list.html +++ b/nouri/templates/items/list.html @@ -5,11 +5,41 @@

{{ item_kind_labels[kind] }}

{{ item_kind_labels[kind] }}

-

Schnell gepflegte Eintraege mit Foto, Tageszeiten und einem ruhigen Status zwischen Idee, Zuhause und Archiv.

+

Schnell gepflegte Einträge mit Foto, Tageszeiten und einem ruhigen Status zwischen Merkliste, Zuhause und Archiv.

Neu anlegen +
+
+ + + +
+ + Zurücksetzen +
+
+
+ {% if items %}
{% for item in items %} @@ -47,6 +77,7 @@
Bearbeiten + Im Tagesplan öffnen
{{ csrf_input() }} @@ -69,9 +100,9 @@
{% else %}
-

Noch keine Eintraege

-

Der schnellste Start ist ein erstes vertrautes Lebensmittel oder eine einfache Mahlzeitenidee.

- Ersten Eintrag anlegen +

Keine passenden Einträge

+

Mit einer kleinen Suche oder einem anderen Filter findest du meist schnell wieder das Richtige.

+ Neuen Eintrag anlegen
{% endif %} {% endblock %} diff --git a/nouri/templates/planner/day.html b/nouri/templates/planner/day.html new file mode 100644 index 0000000..31064fe --- /dev/null +++ b/nouri/templates/planner/day.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}Tagesplan | Nouri{% endblock %} +{% block content %} +
+
+

Tagesplan

+

{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}

+

Der Tagesplan bleibt bewusst ruhig. Jede Tageszeit ist eine eigene Kachel und öffnet sich erst, wenn du sie brauchst.

+
+ +
+ +
+ {% for section in sections %} +
+ +
+
+
+

{{ section.daypart.name }}

+ {% if section.summary_items %} +

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

+ {% else %} +

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

+ {% endif %} +
+
+ {{ section.entries|length }} geplant +
+ +
+ {% if section.quick_items %} +
+ {% for item in section.quick_items %} + + {{ csrf_input() }} + + + + + + {% endfor %} +
+ {% endif %} + +
+ {{ csrf_input() }} + + + + + +
+ + {% if section.entries %} +
+ {% for entry in section.entries %} +
+
+
+ {{ entry.item_name }} + {{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %} +
+
+
+ {{ csrf_input() }} + +
+
+
+ {% if entry.note %} +

{{ entry.note }}

+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

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

+ {% endif %} +
+
+ {% endfor %} +
+{% endblock %} diff --git a/nouri/templates/planner/week.html b/nouri/templates/planner/week.html index eff9ca7..c7b5cc0 100644 --- a/nouri/templates/planner/week.html +++ b/nouri/templates/planner/week.html @@ -1,81 +1,71 @@ {% extends "base.html" %} -{% block title %}Wochenplan | Nouri{% endblock %} +{% block title %}Wochenansicht | Nouri{% endblock %} {% block content %}
-

Wochenplan

-

Struktur fuer die naechsten Tage

-

Der Plan bleibt bewusst leichtgewichtig. Vorhandene Dinge tauchen in der Auswahl zuerst auf.

+

Wochenansicht

+

Ein sanfter Blick auf die nächsten sieben Tage

+

Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben. Wenn etwas noch nicht zuhause ist, landet es dabei automatisch auf der Einkaufsliste.

Vorige Woche - {{ days[0].strftime('%d.%m.') }} bis {{ days[-1].strftime('%d.%m.%Y') }} - Naechste Woche + {{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }} + Nächste Woche
-
-
- {{ csrf_input() }} - - - - - -
-
- -
- {% for daypart in dayparts %} -
-
{{ daypart.name }}
- {% for day in days %} -
-
{{ day.strftime('%a %d.%m.') }}
- {% set slot_entries = entries.get((day.isoformat(), daypart.id), []) %} - {% if slot_entries %} -
- {% for entry in slot_entries %} -
- {{ entry.item_name }} - {{ item_kind_labels[entry.item_kind] }} - {% if entry.note %} -

{{ entry.note }}

- {% endif %} -
- {{ csrf_input() }} - -
-
- {% endfor %} -
- {% else %} -

frei

- {% endif %} +
+ {% for card in week_cards %} +
+
+
+

{{ weekday_name(card.date) }}

+

{{ card.date.strftime('%d.%m.%Y') }}

- {% endfor %} -
+ {% if card.date == today %} + heute + {% endif %} +
+ + {% if card.filled_dayparts %} +

{{ card.planned_count }} Einträge

+
+ {% for slot in card.filled_dayparts %} + {{ slot.name }} · {{ slot.count }} + {% 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 }} +
+ {% if slot.entries %} +
+ {% for entry in slot.entries %} +
+ {{ entry.item_name }} + {{ item_kind_labels[entry.item_kind] }} +
+ {% endfor %} +
+ {% else %} +

Hierher ziehen

+ {% endif %} +
+ {% endfor %} +
+ + + {% endfor %}
{% endblock %} diff --git a/nouri/templates/shopping/list.html b/nouri/templates/shopping/list.html index aa3f95d..51e1977 100644 --- a/nouri/templates/shopping/list.html +++ b/nouri/templates/shopping/list.html @@ -13,7 +13,7 @@
{{ csrf_input() }}