summaryrefslogtreecommitdiff
path: root/kitchen
diff options
context:
space:
mode:
authorCaine <caine@jihakuz.xyz>2026-04-02 23:18:01 +0100
committerCaine <caine@jihakuz.xyz>2026-04-02 23:18:01 +0100
commit557758b188d58a2e00114be21a2e27864db3edea (patch)
tree2ba6cda51f25f2e04bb4af1399bb7602155bec2a /kitchen
parent2f279ac89d66c777e12e4e9f2a0ff0d0aed8883a (diff)
Pantry move/expiry edit + smart shopping list with summary
- Move items between fridge↔freezer (freezer→fridge sets +7d or shelf life expiry, fridge→freezer clears expiry) - Inline expiry date editor on fridge items (📅 button) - Smart shopping list now shows recipe gaps (missing required slot ingredients) - Summary card shows breakdown: X staple restocks, Y expiring, Z recipe gaps
Diffstat (limited to 'kitchen')
-rw-r--r--kitchen/templates/kitchen/partials/pantry_expiry_edit.html17
-rw-r--r--kitchen/templates/kitchen/partials/pantry_table.html19
-rw-r--r--kitchen/templates/kitchen/partials/shopping_list.html11
-rw-r--r--kitchen/templates/kitchen/shopping.html5
-rw-r--r--kitchen/urls_htmx.py8
-rw-r--r--kitchen/views_htmx.py100
6 files changed, 154 insertions, 6 deletions
diff --git a/kitchen/templates/kitchen/partials/pantry_expiry_edit.html b/kitchen/templates/kitchen/partials/pantry_expiry_edit.html
new file mode 100644
index 0000000..ca24716
--- /dev/null
+++ b/kitchen/templates/kitchen/partials/pantry_expiry_edit.html
@@ -0,0 +1,17 @@
+<tr id="pantry-row-{{ item.id }}">
+ <td>{{ item.ingredient.name }}</td>
+ <td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
+ <td colspan="2">
+ <form style="display: flex; gap: 0.4rem; align-items: center;"
+ hx-post="{% url 'app-pantry-save-expiry' item.id %}"
+ hx-target="#pantry-items"
+ hx-swap="innerHTML">
+ <input type="date" name="expiry_date" value="{{ item.expiry_date|date:'Y-m-d' }}" style="font-size: 0.8rem;">
+ <button type="submit" class="btn btn-primary btn-sm">Save</button>
+ <button type="button" class="btn btn-secondary btn-sm"
+ hx-get="{% url 'app-pantry-cancel-edit' %}"
+ hx-target="#pantry-items"
+ hx-swap="innerHTML">Cancel</button>
+ </form>
+ </td>
+</tr>
diff --git a/kitchen/templates/kitchen/partials/pantry_table.html b/kitchen/templates/kitchen/partials/pantry_table.html
index 507eb17..4a8db02 100644
--- a/kitchen/templates/kitchen/partials/pantry_table.html
+++ b/kitchen/templates/kitchen/partials/pantry_table.html
@@ -19,8 +19,18 @@
{% else %}
<span style="color: var(--sage);">—</span>
{% endif %}
+ <button class="btn btn-sm btn-secondary" style="margin-left: 0.25rem; padding: 0.1rem 0.3rem; font-size: 0.7rem;"
+ hx-post="{% url 'app-pantry-edit-expiry' item.id %}"
+ hx-target="#pantry-row-{{ item.id }}"
+ hx-swap="outerHTML"
+ title="Change expiry">📅</button>
</td>
- <td style="text-align: right;">
+ <td style="text-align: right; white-space: nowrap;">
+ <button class="btn btn-sm btn-secondary" style="padding: 0.1rem 0.3rem; font-size: 0.7rem;"
+ hx-post="{% url 'app-pantry-move' item.id %}"
+ hx-target="#pantry-items"
+ hx-vals='{"to": "freezer"}'
+ title="Move to freezer">❄️</button>
<button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items"
@@ -41,7 +51,12 @@
<tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
- <td style="text-align: right;">
+ <td style="text-align: right; white-space: nowrap;">
+ <button class="btn btn-sm btn-secondary" style="padding: 0.1rem 0.3rem; font-size: 0.7rem;"
+ hx-post="{% url 'app-pantry-move' item.id %}"
+ hx-target="#pantry-items"
+ hx-vals='{"to": "fridge"}'
+ title="Defrost → Fridge">🧊→</button>
<button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items"
diff --git a/kitchen/templates/kitchen/partials/shopping_list.html b/kitchen/templates/kitchen/partials/shopping_list.html
index 67e1bfc..0cd20ec 100644
--- a/kitchen/templates/kitchen/partials/shopping_list.html
+++ b/kitchen/templates/kitchen/partials/shopping_list.html
@@ -1,3 +1,14 @@
+{% if summary %}
+<div class="card" style="margin-bottom: 1rem;">
+ <div style="color: var(--teal-light); font-size: 0.85rem;">
+ <strong style="color: var(--yellow);">{{ summary.total }} items</strong> suggested:
+ {% if summary.staples %}<span>{{ summary.staples }} staple restock{{ summary.staples|pluralize }}</span>{% endif %}
+ {% if summary.expiring %}• <span>{{ summary.expiring }} expiring</span>{% endif %}
+ {% if summary.recipe_gaps %}• <span>{{ summary.recipe_gaps }} recipe gap{{ summary.recipe_gaps|pluralize }}</span>{% endif %}
+ </div>
+</div>
+{% endif %}
+
{% regroup items by section as sections %}
{% for section in sections %}
<div class="shop-section">{{ section.grouper|default:"Other" }}</div>
diff --git a/kitchen/templates/kitchen/shopping.html b/kitchen/templates/kitchen/shopping.html
index ba2990f..f5e07e2 100644
--- a/kitchen/templates/kitchen/shopping.html
+++ b/kitchen/templates/kitchen/shopping.html
@@ -4,7 +4,7 @@
{% block content %}
<h1>Shopping List</h1>
-<div style="display: flex; gap: 0.75rem; margin-bottom: 1.5rem;">
+<div style="display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap;">
<button class="btn btn-primary"
hx-post="{% url 'app-shopping-generate' %}"
hx-target="#shopping-items"
@@ -19,6 +19,9 @@
Clear Checked
</button>
</div>
+<p style="color: var(--sage); font-size: 0.8rem; margin-bottom: 1.5rem;">
+ Smart list checks: staples at zero, items expiring within 2 days, and ingredients needed for your recipes.
+</p>
<div id="shopping-items">
{% include "kitchen/partials/shopping_list.html" %}
diff --git a/kitchen/urls_htmx.py b/kitchen/urls_htmx.py
index 390f6c1..5c11447 100644
--- a/kitchen/urls_htmx.py
+++ b/kitchen/urls_htmx.py
@@ -8,9 +8,15 @@ urlpatterns = [
path("shopping/", views_htmx.shopping_page, name="app-shopping"),
path("log/", views_htmx.log_page, name="app-log"),
- # HTMX partials
+ # HTMX partials — pantry
path("pantry/add/", views_htmx.pantry_add, name="app-pantry-add"),
path("pantry/<int:item_id>/delete/", views_htmx.pantry_delete, name="app-pantry-delete"),
+ path("pantry/<int:item_id>/move/", views_htmx.pantry_move, name="app-pantry-move"),
+ path("pantry/<int:item_id>/edit-expiry/", views_htmx.pantry_edit_expiry, name="app-pantry-edit-expiry"),
+ path("pantry/<int:item_id>/save-expiry/", views_htmx.pantry_save_expiry, name="app-pantry-save-expiry"),
+ path("pantry/cancel-edit/", views_htmx.pantry_cancel_edit, name="app-pantry-cancel-edit"),
+
+ # HTMX partials — shopping
path("shopping/generate/", views_htmx.shopping_generate, name="app-shopping-generate"),
path("shopping/<int:item_id>/toggle/", views_htmx.shopping_toggle, name="app-shopping-toggle"),
path("shopping/clear/", views_htmx.shopping_clear, name="app-shopping-clear"),
diff --git a/kitchen/views_htmx.py b/kitchen/views_htmx.py
index 7d011c9..a1e13f3 100644
--- a/kitchen/views_htmx.py
+++ b/kitchen/views_htmx.py
@@ -251,9 +251,65 @@ def pantry_delete(request, item_id):
@csrf_exempt
@require_POST
+def pantry_move(request, item_id):
+ """Move item between fridge/freezer."""
+ item = get_object_or_404(PantryItem, id=item_id)
+ target = request.POST.get("to", "fridge")
+
+ if target == "freezer":
+ item.location = "freezer"
+ item.expiry_date = None
+ elif target == "fridge":
+ item.location = "fridge"
+ # Default +7 days when defrosting
+ if item.ingredient.shelf_life_days:
+ item.expiry_date = date.today() + timedelta(days=item.ingredient.shelf_life_days)
+ else:
+ item.expiry_date = date.today() + timedelta(days=7)
+
+ item.save(update_fields=["location", "expiry_date"])
+ ctx = _pantry_context()
+ return render(request, "kitchen/partials/pantry_table.html", ctx)
+
+
+@csrf_exempt
+@require_POST
+def pantry_edit_expiry(request, item_id):
+ """Show inline expiry date editor."""
+ item = get_object_or_404(PantryItem, id=item_id)
+ return render(request, "kitchen/partials/pantry_expiry_edit.html", {"item": item})
+
+
+@csrf_exempt
+@require_POST
+def pantry_save_expiry(request, item_id):
+ """Save edited expiry date."""
+ item = get_object_or_404(PantryItem, id=item_id)
+ expiry = request.POST.get("expiry_date")
+ if expiry:
+ item.expiry_date = expiry
+ else:
+ item.expiry_date = None
+ item.save(update_fields=["expiry_date"])
+ ctx = _pantry_context()
+ return render(request, "kitchen/partials/pantry_table.html", ctx)
+
+
+def pantry_cancel_edit(request):
+ """Cancel expiry edit — just re-render the table."""
+ ctx = _pantry_context()
+ return render(request, "kitchen/partials/pantry_table.html", ctx)
+
+
+@csrf_exempt
+@require_POST
def shopping_generate(request):
"""Generate smart shopping list and return updated HTML."""
- from .views import _get_section, _find_ingredient as api_find, _is_known_staple
+ from .views import _get_section
+
+ staple_count = 0
+ expiring_count = 0
+ recipe_gap_count = 0
suggestions = []
@@ -263,7 +319,9 @@ def shopping_generate(request):
"ingredient": item.ingredient.name,
"reason": "restock staple",
"section": _get_section(item.ingredient),
+ "type": "staple",
})
+ staple_count += 1
# Expiring items
cutoff = date.today() + timedelta(days=2)
@@ -274,7 +332,34 @@ def shopping_generate(request):
"ingredient": item.ingredient.name,
"reason": f"expiring {item.expiry_date}",
"section": _get_section(item.ingredient),
+ "type": "expiring",
})
+ expiring_count += 1
+
+ # Recipe gaps — check required slots with zero available options
+ for meta in MetaRecipe.objects.prefetch_related(
+ "slots__options__ingredient", "base_ingredients__ingredient"
+ ).all():
+ for slot in meta.slots.all():
+ if not slot.required:
+ continue
+ any_available = False
+ first_option = None
+ for opt in slot.options.all():
+ if not first_option:
+ first_option = opt
+ available = _get_pantry_total(opt.ingredient_id)
+ if available >= opt.quantity_per_serving * 2:
+ any_available = True
+ break
+ if not any_available and first_option:
+ suggestions.append({
+ "ingredient": first_option.ingredient.name,
+ "reason": f"for {meta.name} ({slot.name})",
+ "section": _get_section(first_option.ingredient),
+ "type": "recipe",
+ })
+ recipe_gap_count += 1
# Dedupe and save
seen = set()
@@ -294,7 +379,18 @@ def shopping_generate(request):
items = ShoppingListItem.objects.select_related("ingredient").all()
for item in items:
item.section = _get_section_for_item(item)
- return render(request, "kitchen/partials/shopping_list.html", {"items": items})
+
+ summary = {
+ "total": len(set(s["ingredient"] for s in suggestions)),
+ "staples": staple_count,
+ "expiring": expiring_count,
+ "recipe_gaps": recipe_gap_count,
+ }
+
+ return render(request, "kitchen/partials/shopping_list.html", {
+ "items": items,
+ "summary": summary,
+ })
@csrf_exempt