diff --git a/nouri/db.py b/nouri/db.py index b127ade..cf11809 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -487,6 +487,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: add_column_if_missing(database, "shopping_entries", "household_id INTEGER") add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER") add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'") + add_column_if_missing(database, "shopping_entries", "shopping_note TEXT NOT NULL DEFAULT ''") add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT") add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER") @@ -740,6 +741,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: 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, "shopping_entries", "shopping_note TEXT NOT NULL DEFAULT ''") add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'") add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'") add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'") @@ -803,6 +805,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: 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 shopping_entries SET shopping_note = '' WHERE shopping_note 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 = ''") @@ -848,6 +851,14 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ON shopping_entries (household_id, visibility, is_checked) """ ) + database.execute("DROP INDEX IF EXISTS idx_shopping_entries_open_item") + database.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item + ON shopping_entries (item_id, COALESCE(shopping_note, '')) + WHERE is_checked = 0 + """ + ) database.execute( """ CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation diff --git a/nouri/main.py b/nouri/main.py index 79431e0..ea2f736 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -1001,6 +1001,10 @@ def should_activate_shopping_need(needed_date: date, today: date | None = None) return (today or date.today()) >= shopping_activation_date_for(needed_date) +def normalize_shopping_note(value: str | None) -> str: + return " ".join((value or "").strip().split())[:80] + + def schedule_shopping_need( *, item_id: int, @@ -1082,14 +1086,16 @@ def add_to_shopping_list( visibility_override: str | None = None, needed_for_date: str | None = None, needed_for_daypart_id: int | None = None, + shopping_note: str | None = None, ) -> bool: item = get_item(item_id) + normalized_note = normalize_shopping_note(shopping_note) existing = get_db().execute( """ SELECT id FROM shopping_entries - WHERE item_id = ? AND is_checked = 0 + WHERE item_id = ? AND shopping_note = ? AND is_checked = 0 """, - (item_id,), + (item_id, normalized_note), ).fetchone() if existing: return False @@ -1099,15 +1105,16 @@ def add_to_shopping_list( get_db().execute( """ INSERT INTO shopping_entries ( - household_id, owner_user_id, visibility, item_id, added_by, needed_for_date, needed_for_daypart_id + household_id, owner_user_id, visibility, item_id, shopping_note, added_by, needed_for_date, needed_for_daypart_id ) - VALUES (?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), owner_user_id, visibility, item_id, + normalized_note, user_id, needed_for_date, needed_for_daypart_id, @@ -1150,6 +1157,7 @@ def ensure_item_or_missing_components_are_shopped( needed_for_date: str | None = None, needed_for_daypart_id: int | None = None, source_item_id: int | None = None, + shopping_note: str | None = None, ) -> dict: item = get_item(item_id) if item["kind"] == "meal": @@ -1219,6 +1227,7 @@ def ensure_item_or_missing_components_are_shopped( visibility_override=visibility, needed_for_date=needed_for_date, needed_for_daypart_id=needed_for_daypart_id, + shopping_note=shopping_note, ) return { "added": added, @@ -1289,6 +1298,39 @@ def fetch_items_by_ids(item_ids: list[int]) -> list[dict]: return [items_by_id[item_id] for item_id in normalized_ids if item_id in items_by_id] +def find_shopping_food_by_name(name: str) -> dict | None: + normalized_name = name.strip().lower() + if not normalized_name: + return None + row = get_db().execute( + f""" + SELECT items.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username, + target.display_name AS target_display_name, + target.username AS target_username, + EXISTS( + SELECT 1 + FROM shopping_entries + WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0 + ) AS is_on_shopping_list + FROM items + LEFT JOIN users AS owner ON owner.id = items.owner_user_id + LEFT JOIN users AS target ON target.id = items.target_user_id + WHERE items.kind = 'food' + AND items.is_archived = 0 + AND LOWER(items.name) = ? + AND {visible_clause('items')} + ORDER BY LOWER(items.name), items.id + LIMIT 1 + """, + [normalized_name, *visible_params()], + ).fetchone() + if row is None: + return None + return attach_builder_keys(attach_dayparts(describe_records([row])))[0] + + def fetch_shopping_entries(): rows = get_db().execute( f""" @@ -4385,6 +4427,56 @@ def remove_shopping_entry(entry_id: int) -> None: get_db().commit() +@main_bp.post("/shopping//note") +@login_required +def shopping_update_note(entry_id: int): + entry = get_db().execute( + f""" + SELECT shopping_entries.*, + owner.display_name AS owner_display_name, + owner.username AS owner_username + FROM shopping_entries + LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id + WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')} + """, + [entry_id, *visible_params()], + ).fetchone() + if entry is None: + flash("Der Einkaufseintrag wurde nicht gefunden.", "error") + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) + + try: + ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht ändern.") + except PermissionError as exc: + flash(str(exc), "error") + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) + + shopping_note = normalize_shopping_note(request.form.get("shopping_note")) + duplicate = get_db().execute( + """ + SELECT id + FROM shopping_entries + WHERE item_id = ? + AND shopping_note = ? + AND is_checked = 0 + AND id != ? + LIMIT 1 + """, + (entry["item_id"], shopping_note, entry_id), + ).fetchone() + if duplicate: + flash("Dieser Hinweis steht für das Lebensmittel schon auf der Einkaufsliste.", "info") + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) + + get_db().execute( + "UPDATE shopping_entries SET shopping_note = ? WHERE id = ?", + (shopping_note, entry_id), + ) + get_db().commit() + flash("Der Einkaufshinweis wurde gespeichert.", "success") + return redirect(url_with_scroll_position(url_for("main.shopping_list"))) + + @main_bp.post("/items//shopping/bought") @login_required def item_mark_bought(item_id: int): @@ -4436,30 +4528,40 @@ def item_remove_from_shopping(item_id: int): def shopping_list(): if request.method == "POST": selected_item_id = request.form.get("item_id", "").strip() - if not selected_item_id.isdigit(): - flash("Bitte zuerst etwas auswählen.", "error") - else: + item_search = request.form.get("item_search", "").strip() + shopping_note = normalize_shopping_note(request.form.get("shopping_note")) + item = None + if selected_item_id.isdigit(): try: item = get_item(int(selected_item_id)) - result = ensure_item_or_missing_components_are_shopped( - item["id"], - g.user["id"], - item["visibility"], - ) - if result["count"]: - flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}.", "success") - elif result["scheduled_count"]: - flash("Ein paar Dinge sind für einen späteren Einkauf vorgemerkt.", "info") - else: - flash("Dafür ist gerade nichts zusätzlich nötig.", "info") except ValueError as exc: flash(str(exc), "error") + elif item_search: + item = find_shopping_food_by_name(item_search) + if item is None: + flash("Bitte ein Lebensmittel aus der Suche auswählen.", "error") + else: + flash("Bitte zuerst etwas auswählen.", "error") + + if item is not None: + result = ensure_item_or_missing_components_are_shopped( + item["id"], + g.user["id"], + item["visibility"], + shopping_note=shopping_note, + ) + if result["count"]: + note_suffix = f" ({shopping_note})" if shopping_note else "" + flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}{note_suffix}.", "success") + elif result["scheduled_count"]: + flash("Ein paar Dinge sind für einen späteren Einkauf vorgemerkt.", "info") + else: + flash("Dieser Einkaufseintrag steht so schon auf der Liste.", "info") return redirect(url_with_scroll_position(url_for("main.shopping_list"))) entries = fetch_shopping_entries() upcoming_entries = fetch_upcoming_shopping_needs() - addable_items = fetch_items(include_archived=True, include_quick_added=True) - addable_items = [item for item in addable_items if item["kind"] == "food" and not item["is_on_shopping_list"]] + addable_items = fetch_items(kind="food", include_archived=False, include_quick_added=True) household_settings = get_household_settings() shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt") return render_template( diff --git a/nouri/schema.sql b/nouri/schema.sql index ac14e3e..a7652ab 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -167,6 +167,7 @@ CREATE TABLE IF NOT EXISTS shopping_entries ( owner_user_id INTEGER, visibility TEXT NOT NULL DEFAULT 'shared', item_id INTEGER NOT NULL, + shopping_note TEXT NOT NULL DEFAULT '', added_by INTEGER, checked_by INTEGER, needed_for_date TEXT, @@ -183,7 +184,7 @@ CREATE TABLE IF NOT EXISTS shopping_entries ( ); CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item -ON shopping_entries (item_id) +ON shopping_entries (item_id, COALESCE(shopping_note, '')) WHERE is_checked = 0; CREATE TABLE IF NOT EXISTS shopping_needs ( diff --git a/nouri/static/css/styles.css b/nouri/static/css/styles.css index 18ed17a..bcd4217 100644 --- a/nouri/static/css/styles.css +++ b/nouri/static/css/styles.css @@ -1219,6 +1219,13 @@ h3 { grid-column: 1 / -1; } +.shopping-add-form { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(11rem, 0.8fr) auto; + gap: 0.8rem; + align-items: end; +} + .shopping-add-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); @@ -1244,6 +1251,9 @@ h3 { padding: 0.85rem 0.95rem; border-radius: 22px; text-align: left; + background: color-mix(in srgb, var(--surface-strong) 86%, var(--accent-soft) 14%); + color: var(--text); + border-color: color-mix(in srgb, var(--line) 72%, var(--accent) 28%); } .shopping-add-card-visual, @@ -1342,6 +1352,20 @@ h3 { margin: 0; } +.shopping-entry-note { + width: fit-content; + max-width: 100%; + margin: 0; + padding: 0.12rem 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--accent-soft) 56%, transparent 44%); + color: color-mix(in srgb, var(--accent-strong) 70%, var(--text) 30%); + font-size: 0.82rem; + font-weight: 700; + line-height: 1.35; + overflow-wrap: anywhere; +} + .shopping-entry-actions { display: flex; align-items: center; @@ -1426,6 +1450,14 @@ h3 { } @media (max-width: 680px) { + .shopping-add-form { + grid-template-columns: 1fr; + } + + .shopping-add-form button { + width: 100%; + } + .shopping-entry-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; @@ -1462,6 +1494,10 @@ h3 { display: none; } + .shopping-entry-note { + font-size: 0.78rem; + } + .shopping-entry-actions, .shopping-entry-actions form, .shopping-entry-actions button, @@ -1504,6 +1540,31 @@ h3 { border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.18) 42%); } +[data-theme="dark"] button.shopping-add-card, +[data-theme="dark"] .shopping-add-card { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-soft) 70%, #4a3e3a 30%), + color-mix(in srgb, var(--surface) 92%, #241f1d 8%) + ); + color: var(--text); + border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%); + box-shadow: none; +} + +[data-theme="dark"] button.shopping-add-card:hover, +[data-theme="dark"] .shopping-add-card:hover { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-soft) 62%, #5a4840 38%), + color-mix(in srgb, var(--surface) 88%, #2f2724 12%) + ); +} + +[data-theme="dark"] .shopping-add-card-copy small { + color: color-mix(in srgb, var(--muted) 86%, white 14%); +} + [data-theme="dark"] .shopping-entry-card { background: linear-gradient( 180deg, @@ -1513,6 +1574,11 @@ h3 { border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.14) 42%); } +[data-theme="dark"] .shopping-entry-note { + background: color-mix(in srgb, var(--accent-soft) 54%, rgba(32, 27, 25, 0.46) 46%); + color: color-mix(in srgb, var(--accent-strong) 82%, white 18%); +} + .auth-shell { min-height: calc(100vh - 10rem); display: grid; @@ -1839,6 +1905,7 @@ legend { border-radius: 18px; border: 1px solid var(--line); background: color-mix(in srgb, var(--surface-strong) 82%, #fff 18%); + color: var(--text); } .quick-select-card strong, @@ -1856,6 +1923,20 @@ legend { color: var(--muted); } +[data-theme="dark"] .quick-select-card { + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--surface-soft) 70%, #4a3e3a 30%), + color-mix(in srgb, var(--surface) 92%, #241f1d 8%) + ); + border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%); + color: var(--text); +} + +[data-theme="dark"] .quick-select-card small { + color: color-mix(in srgb, var(--muted) 86%, white 14%); +} + .inline-photo img { width: min(220px, 100%); border-radius: 18px; diff --git a/nouri/templates/shopping/list.html b/nouri/templates/shopping/list.html index ac1eed0..910eddf 100644 --- a/nouri/templates/shopping/list.html +++ b/nouri/templates/shopping/list.html @@ -10,68 +10,34 @@
-
+
+ {{ csrf_input() }} -
+ + {% for item in addable_items %} - {% set item_icon_class = { - 'protein': 'icon-component-protein', - 'carb': 'icon-component-carb', - 'veg': 'icon-component-veg', - 'fruit': 'icon-component-fruit', - 'dairy': 'icon-component-dairy', - 'nuts': 'icon-component-nuts', - 'seeds': 'icon-component-seeds', - 'neutral': 'icon-component-neutral', - }.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %} - - {{ csrf_input() }} - - - - {% else %} -

Gerade ist nichts zusätzlich offen.

+ {% endfor %} -
-
+ + +
{% if entries %} @@ -119,6 +85,9 @@
{{ entry.item_name }} + {% if entry.shopping_note %} +

{{ entry.shopping_note }}

+ {% endif %} {% if entry.needed_for_label %}

Für {{ entry.needed_for_label }} @@ -153,7 +122,9 @@

{{ entry.item_name }}

- {% if entry.needed_for_label %} + {% if entry.shopping_note %} + {{ entry.shopping_note }} + {% elif entry.needed_for_label %} Für {{ entry.needed_for_label }} {% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %} {% elif entry.is_home %} @@ -174,6 +145,20 @@ {% if entry.can_edit %} +

+ {{ csrf_input() }} + + +
{{ csrf_input() }}