diff options
| -rw-r--r-- | kitchen/templates/kitchen/partials/pantry_expiry_edit.html | 17 | ||||
| -rw-r--r-- | kitchen/templates/kitchen/partials/pantry_table.html | 19 | ||||
| -rw-r--r-- | kitchen/templates/kitchen/partials/shopping_list.html | 11 | ||||
| -rw-r--r-- | kitchen/templates/kitchen/shopping.html | 5 | ||||
| -rw-r--r-- | kitchen/urls_htmx.py | 8 | ||||
| -rw-r--r-- | kitchen/views_htmx.py | 100 |
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 |
