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
This commit is contained in:
Caine
2026-04-02 23:18:01 +01:00
parent 726cb9d153
commit d0de1b44ea
6 changed files with 154 additions and 6 deletions
@@ -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>
@@ -19,8 +19,18 @@
{% else %} {% else %}
<span style="color: var(--sage);"></span> <span style="color: var(--sage);"></span>
{% endif %} {% 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>
<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" <button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}" hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items" hx-target="#pantry-items"
@@ -41,7 +51,12 @@
<tr id="pantry-row-{{ item.id }}"> <tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}</td> <td>{{ item.ingredient.name }}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</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" <button class="btn btn-danger btn-sm"
hx-delete="{% url 'app-pantry-delete' item.id %}" hx-delete="{% url 'app-pantry-delete' item.id %}"
hx-target="#pantry-items" hx-target="#pantry-items"
@@ -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 %} {% regroup items by section as sections %}
{% for section in sections %} {% for section in sections %}
<div class="shop-section">{{ section.grouper|default:"Other" }}</div> <div class="shop-section">{{ section.grouper|default:"Other" }}</div>
+4 -1
View File
@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<h1>Shopping List</h1> <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" <button class="btn btn-primary"
hx-post="{% url 'app-shopping-generate' %}" hx-post="{% url 'app-shopping-generate' %}"
hx-target="#shopping-items" hx-target="#shopping-items"
@@ -19,6 +19,9 @@
Clear Checked Clear Checked
</button> </button>
</div> </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"> <div id="shopping-items">
{% include "kitchen/partials/shopping_list.html" %} {% include "kitchen/partials/shopping_list.html" %}
+7 -1
View File
@@ -8,9 +8,15 @@ urlpatterns = [
path("shopping/", views_htmx.shopping_page, name="app-shopping"), path("shopping/", views_htmx.shopping_page, name="app-shopping"),
path("log/", views_htmx.log_page, name="app-log"), 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/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>/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/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/<int:item_id>/toggle/", views_htmx.shopping_toggle, name="app-shopping-toggle"),
path("shopping/clear/", views_htmx.shopping_clear, name="app-shopping-clear"), path("shopping/clear/", views_htmx.shopping_clear, name="app-shopping-clear"),
+98 -2
View File
@@ -249,11 +249,67 @@ def pantry_delete(request, item_id):
return render(request, "kitchen/partials/pantry_table.html", ctx) return render(request, "kitchen/partials/pantry_table.html", ctx)
@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 @csrf_exempt
@require_POST @require_POST
def shopping_generate(request): def shopping_generate(request):
"""Generate smart shopping list and return updated HTML.""" """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 = [] suggestions = []
@@ -263,7 +319,9 @@ def shopping_generate(request):
"ingredient": item.ingredient.name, "ingredient": item.ingredient.name,
"reason": "restock staple", "reason": "restock staple",
"section": _get_section(item.ingredient), "section": _get_section(item.ingredient),
"type": "staple",
}) })
staple_count += 1
# Expiring items # Expiring items
cutoff = date.today() + timedelta(days=2) cutoff = date.today() + timedelta(days=2)
@@ -274,7 +332,34 @@ def shopping_generate(request):
"ingredient": item.ingredient.name, "ingredient": item.ingredient.name,
"reason": f"expiring {item.expiry_date}", "reason": f"expiring {item.expiry_date}",
"section": _get_section(item.ingredient), "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 # Dedupe and save
seen = set() seen = set()
@@ -294,7 +379,18 @@ def shopping_generate(request):
items = ShoppingListItem.objects.select_related("ingredient").all() items = ShoppingListItem.objects.select_related("ingredient").all()
for item in items: for item in items:
item.section = _get_section_for_item(item) 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 @csrf_exempt