diff --git a/CloudronManifest.json b/CloudronManifest.json index 63a35f3..070d68e 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.2.2", - "upstreamVersion": "1.2.2", + "version": "1.3.0", + "upstreamVersion": "1.3.0", "healthCheckPath": "/", "httpPort": 8000, "manifestVersion": 2, diff --git a/RELEASE_NOTES_1.3.0.md b/RELEASE_NOTES_1.3.0.md new file mode 100644 index 0000000..8e2a639 --- /dev/null +++ b/RELEASE_NOTES_1.3.0.md @@ -0,0 +1,50 @@ +# Nouri 1.3.0 + +Nouri 1.3.0 ist ein größerer Alltags- und Pflege-Release auf Basis von 1.2.2. Der Schwerpunkt liegt auf klareren Lebensmittel-Zuständen, einer ruhigeren Einkaufsliste und Formularen, die beim Bearbeiten nicht mehr aus dem Arbeitsfluss reißen. + +## Neu in 1.3.0 + +- Lebensmittel haben jetzt eine klarere Alltagslogik: + - `Zuhause` ist ein Bestandsstatus + - `Gerade nicht da` ist ein fehlender Bestand + - `Archiviert` bleibt eine bewusste Entscheidung + - `Unsortiert` ist ein neuer Zwischenstatus für schnelle Sammelerfassung +- Über `Schnell anlegen` lassen sich mehrere Lebensmittel auf einmal erfassen. + - Die Einträge landen zunächst in `Unsortiert` + - sie tauchen erst nach späterer Einordnung regulär im Alltag auf +- Die Lebensmittelkarten wurden deutlich vereinfacht: + - Bild oder passendes Icon + - Titel + - Bearbeitung über die ganze Kachel + - Archivieren über ein kleines `x` + - Zusatzinfos nur noch als ruhige Hover-Ebene +- Die Einkaufsliste wurde klarer und direkter: + - Suche nach Lebensmitteln statt Dropdown + - Einträge mit Bild oder passendem Icon + - Bearbeitung über Popup + - Archivierte und unsortierte Lebensmittel können ebenfalls über die Suche wieder auf die Einkaufsliste gesetzt werden + +## Enthaltene Feinschliffe seit 1.2.2 + +- Die Lebensmittel- und Mahlzeitenlogik wurde weiter geschärft: + - Geschmacksrichtung `süß`, `herzhaft`, `neutral` + - bessere Filterung kulinarisch passender Vorschläge + - klarere Builder-Begriffe und ruhigere Formulare +- Auswahlbereiche für Tageszeiten, Mahlzeit-Charakter und Builder-Felder wurden weiter vereinheitlicht. +- Ausgewählte Zutaten in Mahlzeiten und Paketen sind sichtbarer und leichter bearbeitbar. +- Mehr Formulare unterstützen jetzt einen ruhigeren Bearbeitungsfluss: + - `Speichern` + - `Speichern und schließen` + - Scroll-Position bleibt beim Weiterbearbeiten erhalten +- Die Navigation und mehrere Detailansichten wurden weiter beruhigt und konsistenter gemacht. + +## Technisch + +- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.0`. +- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.0` angehoben. +- Die Datenbank kennt jetzt zusätzliche Zustände und Felder für die überarbeitete Lebensmittel-Logik, unter anderem für `Unsortiert`. + +## Hinweis zum Update + +- Beim Cloudron-Update sollte der `data`-Ordner weiterhin schreibbar sein, damit Schema-Änderungen sauber angewendet werden können. +- Für produktive Updates bleibt ein reguläres Cloudron-Backup vor dem Rollout die sichere Variante. diff --git a/nouri/__init__.py b/nouri/__init__.py index b610bfd..69143f6 100644 --- a/nouri/__init__.py +++ b/nouri/__init__.py @@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str: ).strip() if manifest_version: return manifest_version - return "1.2.2" + return "1.3.0" def load_release_url() -> str: diff --git a/nouri/admin.py b/nouri/admin.py index c820192..4ec088a 100644 --- a/nouri/admin.py +++ b/nouri/admin.py @@ -3,7 +3,7 @@ from __future__ import annotations from flask import Blueprint, flash, g, redirect, render_template, request, url_for from werkzeug.security import generate_password_hash -from .auth import admin_required, can_remove_last_admin, validate_admin_user_form +from .auth import admin_required, can_remove_last_admin, url_with_scroll_position, validate_admin_user_form, wants_to_stay_on_form from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS from .db import get_db @@ -104,6 +104,19 @@ def user_create(): ) database.commit() flash("Der Nutzer wurde angelegt.", "success") + if wants_to_stay_on_form(): + new_user = database.execute( + """ + SELECT id + FROM users + WHERE household_id = ? AND username = ? + ORDER BY id DESC + LIMIT 1 + """, + (g.user["household_id"], form_data["username"]), + ).fetchone() + if new_user is not None: + return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=int(new_user["id"])))) return redirect(url_for("admin.user_list")) flash(error, "error") @@ -185,6 +198,8 @@ def user_edit(user_id: int): ) database.commit() flash("Der Nutzer wurde aktualisiert.", "success") + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=user_id))) return redirect(url_for("admin.user_list")) flash(error, "error") diff --git a/nouri/auth.py b/nouri/auth.py index 52ff0b0..c2c54c6 100644 --- a/nouri/auth.py +++ b/nouri/auth.py @@ -2,6 +2,7 @@ from __future__ import annotations import functools import secrets +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from flask import ( Blueprint, @@ -25,23 +26,23 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/auth") def login_required(view): @functools.wraps(view) - def wrapped_view(**kwargs): + def wrapped_view(*args, **kwargs): if g.user is None: return redirect(url_for("auth.login")) - return view(**kwargs) + return view(*args, **kwargs) return wrapped_view def admin_required(view): @functools.wraps(view) - def wrapped_view(**kwargs): + def wrapped_view(*args, **kwargs): if g.user is None: return redirect(url_for("auth.login")) if g.user["role"] != "admin": flash("Dieser Bereich ist für Admins gedacht.", "error") return redirect(url_for("main.dashboard")) - return view(**kwargs) + return view(*args, **kwargs) return wrapped_view @@ -53,6 +54,25 @@ def ensure_csrf_token() -> str: return token +def wants_to_stay_on_form() -> bool: + return request.form.get("save_mode", "").strip() == "stay" + + +def url_with_scroll_position(url: str) -> str: + raw_scroll = request.form.get("_scroll", "").strip() + if not raw_scroll: + return url + try: + scroll_value = max(0, int(float(raw_scroll))) + except ValueError: + return url + + parts = urlsplit(url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["_scroll"] = str(scroll_value) + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + def normalize_login_value(raw: str) -> str: return raw.strip().lower() @@ -231,7 +251,9 @@ def profile(): ) database.commit() flash("Dein Profil wurde aktualisiert.", "success") - return redirect(url_for("auth.profile")) + if wants_to_stay_on_form(): + return redirect(url_with_scroll_position(url_for("auth.profile"))) + return redirect(url_for("main.dashboard")) flash(error, "error") diff --git a/nouri/constants.py b/nouri/constants.py index 5034411..cf5f8c3 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -17,6 +17,7 @@ DAYPART_SLUG_TO_MEAL_TYPE = { } DEFAULT_CATEGORIES = [ + "Unsortiert", "Kohlenhydrate", "Milchprodukt", "Obst", @@ -30,6 +31,7 @@ DEFAULT_CATEGORIES = [ ] DEFAULT_CATEGORY_BUILDERS = { + "Unsortiert": "neutral", "Kohlenhydrate": "carb", "Brot & Getreide": "carb", "Milchprodukt": "dairy", @@ -226,9 +228,10 @@ ITEM_KIND_SINGULAR_LABELS = { } AVAILABILITY_LABELS = { - "idea": "Merkliste", + "idea": "Gerade nicht da", "home": "Zuhause", - "archived": "Archiv", + "unsorted": "Unsortiert", + "archived": "Archiviert", } ROLE_LABELS = { diff --git a/nouri/db.py b/nouri/db.py index 33a4cf9..b127ade 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -15,7 +15,7 @@ from .constants import ( DEFAULT_CATEGORY_BUILDERS, ) -CURRENT_SCHEMA_VERSION = "1.2.2" +CURRENT_SCHEMA_VERSION = "1.3.0" ANIMAL_HINTS = ( "huhn", @@ -344,6 +344,19 @@ def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None: set_meta(database, "food_flavor_profiles_migrated", "1") +def migrate_item_archive_state(database: sqlite3.Connection) -> None: + if get_meta(database, "item_archive_state_migrated") == "1": + return + + if "is_archived" not in table_columns(database, "items"): + return + + database.execute("UPDATE items SET is_archived = 0 WHERE is_archived IS NULL") + database.execute("UPDATE items SET is_archived = 1 WHERE availability_state = 'archived'") + database.execute("UPDATE items SET availability_state = 'idea' WHERE availability_state = 'archived'") + set_meta(database, "item_archive_state_migrated", "1") + + def get_db() -> sqlite3.Connection: if "db" not in g: g.db = sqlite3.connect( @@ -468,6 +481,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: add_column_if_missing(database, "items", "meal_type TEXT") add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''") add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0") if table_exists(database, "shopping_entries"): add_column_if_missing(database, "shopping_entries", "household_id INTEGER") @@ -722,6 +736,8 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, "items", "meal_type TEXT") add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''") add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0") + add_column_if_missing(database, "items", "is_quick_added INTEGER NOT NULL DEFAULT 0") add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'") @@ -771,6 +787,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: sync_default_categories(database) migrate_item_profiles(database) migrate_food_flavor_profiles(database) + migrate_item_archive_state(database) database.execute( """ INSERT OR IGNORE INTO user_settings (user_id) @@ -784,6 +801,8 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''") database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL") database.execute("UPDATE items SET meal_tags = '' WHERE meal_tags IS NULL") + database.execute("UPDATE items SET is_archived = 0 WHERE is_archived IS NULL") + database.execute("UPDATE items SET is_quick_added = 0 WHERE is_quick_added IS NULL") database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''") database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''") database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''") @@ -805,6 +824,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON items (household_id, visibility, availability_state) """ ) + database.execute( + """ + CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived + ON items (household_id, visibility, is_archived, availability_state) + """ + ) database.execute( """ CREATE INDEX IF NOT EXISTS idx_items_target_user diff --git a/nouri/schema.sql b/nouri/schema.sql index f3c72ee..ac14e3e 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -132,6 +132,8 @@ CREATE TABLE IF NOT EXISTS items ( note TEXT, photo_filename TEXT, availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')), + is_archived INTEGER NOT NULL DEFAULT 0, + is_quick_added INTEGER NOT NULL DEFAULT 0, created_by INTEGER, updated_by INTEGER, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -304,6 +306,9 @@ ON items (kind, name); CREATE INDEX IF NOT EXISTS idx_items_household_visibility ON items (household_id, visibility, availability_state); +CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived +ON items (household_id, visibility, is_archived, availability_state); + CREATE INDEX IF NOT EXISTS idx_items_target_user ON items (target_user_id); diff --git a/nouri/static/js/ui.js b/nouri/static/js/ui.js index 390afeb..c7b58a6 100644 --- a/nouri/static/js/ui.js +++ b/nouri/static/js/ui.js @@ -1,4 +1,62 @@ (() => { + const scrollStorageKey = () => `nouri-scroll:${window.location.pathname}${window.location.search}`; + + const initPostFormScrollMemory = () => { + const restoreFromUrl = () => { + const currentUrl = new URL(window.location.href); + const rawScroll = currentUrl.searchParams.get("_scroll"); + if (!rawScroll) return false; + + const scrollValue = Number.parseInt(rawScroll, 10); + currentUrl.searchParams.delete("_scroll"); + + window.requestAnimationFrame(() => { + if (Number.isFinite(scrollValue)) { + window.scrollTo({ top: scrollValue, left: 0, behavior: "auto" }); + } + window.history.replaceState({}, "", currentUrl.toString()); + }); + return true; + }; + + const restoreFromStorage = () => { + const savedScroll = sessionStorage.getItem(scrollStorageKey()); + if (!savedScroll) return; + + sessionStorage.removeItem(scrollStorageKey()); + const scrollValue = Number.parseInt(savedScroll, 10); + if (!Number.isFinite(scrollValue)) return; + + window.requestAnimationFrame(() => { + window.scrollTo({ top: scrollValue, left: 0, behavior: "auto" }); + }); + }; + + if (!restoreFromUrl()) { + restoreFromStorage(); + } + + document.addEventListener("submit", (event) => { + const form = event.target; + if (!(form instanceof HTMLFormElement)) return; + + const method = (form.getAttribute("method") || "get").toLowerCase(); + if (method !== "post") return; + + const scrollValue = String(Math.round(window.scrollY)); + sessionStorage.setItem(scrollStorageKey(), scrollValue); + + let scrollInput = form.querySelector('input[name="_scroll"]'); + if (!(scrollInput instanceof HTMLInputElement)) { + scrollInput = document.createElement("input"); + scrollInput.type = "hidden"; + scrollInput.name = "_scroll"; + form.appendChild(scrollInput); + } + scrollInput.value = scrollValue; + }); + }; + const initMobileSheet = () => { const sheet = document.querySelector("[data-mobile-sheet]"); const navStack = document.querySelector("[data-mobile-nav-stack]"); @@ -153,9 +211,66 @@ }, { passive: false }); }; + const initDialogs = () => { + document.addEventListener("click", (event) => { + const openButton = event.target.closest("[data-dialog-open]"); + if (openButton instanceof HTMLElement) { + const dialogId = openButton.getAttribute("data-dialog-open"); + if (!dialogId) return; + const dialog = document.getElementById(dialogId); + if (dialog instanceof HTMLDialogElement) { + dialog.showModal(); + } + return; + } + + const closeButton = event.target.closest("[data-dialog-close]"); + if (closeButton instanceof HTMLElement) { + const dialog = closeButton.closest("dialog"); + if (dialog instanceof HTMLDialogElement) { + dialog.close(); + } + } + }); + + document.addEventListener("keydown", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const openButton = target.closest("[data-dialog-open]"); + if (!(openButton instanceof HTMLElement)) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + const dialogId = openButton.getAttribute("data-dialog-open"); + if (!dialogId) return; + const dialog = document.getElementById(dialogId); + if (dialog instanceof HTMLDialogElement) { + dialog.showModal(); + } + }); + + document.querySelectorAll("dialog").forEach((dialog) => { + dialog.addEventListener("click", (event) => { + if (event.target === dialog && dialog instanceof HTMLDialogElement) { + dialog.close(); + } + }); + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + document.querySelectorAll("dialog[open]").forEach((dialog) => { + if (dialog instanceof HTMLDialogElement) { + dialog.close(); + } + }); + }); + }; + document.addEventListener("DOMContentLoaded", () => { + initPostFormScrollMemory(); initMobileSheet(); initFilterInputs(); initIosPullToRefresh(); + initDialogs(); }); })(); diff --git a/nouri/templates/admin/user_form.html b/nouri/templates/admin/user_form.html index e18a2c4..bc64622 100644 --- a/nouri/templates/admin/user_form.html +++ b/nouri/templates/admin/user_form.html @@ -45,7 +45,8 @@
Archiv
Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.
+Archiv bedeutet bewusst ausgeblendet, nicht verbraucht. Von hier aus lassen sich Dinge jederzeit wieder aktivieren.
Zuhause
Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, bleibt es später im Archiv greifbar.
+Hier erscheinen aktive Lebensmittel und Mahlzeitenideen, die gerade wirklich da sind. Wenn etwas leer ist, wird es einfach als gerade nicht da markiert.
Lebensmittel
+Hier kannst du mehrere Lebensmittel in einem Schritt anlegen. Sie landen zuerst als ruhige Platzhalter auf der Einkaufsliste und lassen sich später einzeln weiter ordnen.
+