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:
@@ -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,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" %}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user