From 0aabf50834a31ff4ae9f8fd58639e444a449110b Mon Sep 17 00:00:00 2001 From: Tom Flux Date: Tue, 23 Jun 2026 19:55:16 +0100 Subject: Phase 1: mobile-first pantry redesign with In/Low/Out state Track presence (In/Low/Out) as the primary signal instead of exact quantities; quantity becomes an optional integer, unit optional too (migration 0003 backfills state from quantity: 0 -> out, else in). - Pantry rebuilt as a phone-first card list: colored state rail, segmented In/Low/Out switch (server-driven HTMX), greyed out-items, live summary counts. - Fast add: bottom add bar with type-ahead autocomplete, location picker, and quick-add chips for things you've run out of. - Per-item menu (move / set expiry / delete); expiry is now an optional quiet pill, never required. - New endpoints: pantry search + set-state; dropped the old edit-expiry/cancel flow and its partial. - Recipes matcher made presence-based (have-it beats have-enough); state added to admin. Tests cover state, add/restock, search, presence matching, and page rendering. Co-Authored-By: Claude Opus 4.8 --- kitchen/views_htmx.py | 152 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 97 insertions(+), 55 deletions(-) (limited to 'kitchen/views_htmx.py') diff --git a/kitchen/views_htmx.py b/kitchen/views_htmx.py index e9e8569..21eec8a 100644 --- a/kitchen/views_htmx.py +++ b/kitchen/views_htmx.py @@ -22,23 +22,55 @@ from .helpers import ( # --- Helpers --- +_STATE_RANK = {"in": 0, "low": 1, "out": 2} + + def _pantry_context(): - """Build pantry items grouped by location with expiry annotations.""" - items = PantryItem.objects.select_related("ingredient").filter(quantity__gt=0) + """All pantry items grouped by location with state + expiry annotations. + + Shows everything (including 'out' items, greyed in the UI) — presence is the + state field, not a quantity filter. Within a group, in/low sort before out. + """ + items = list(PantryItem.objects.select_related("ingredient").all()) today = date.today() for item in items: - item.is_expired = item.expiry_date and item.expiry_date < today - item.expiring_soon = ( + item.is_expired = bool(item.expiry_date and item.expiry_date < today) + item.expiring_soon = bool( item.expiry_date and not item.is_expired and (item.expiry_date - today).days <= 2 ) + item.days_left = ( + (item.expiry_date - today).days + if item.expiry_date and not item.is_expired + else None + ) + + def by_location(loc): + return sorted( + (i for i in items if i.location == loc), + key=lambda i: (_STATE_RANK.get(i.state, 3), i.ingredient.name.lower()), + ) + + # Quick-add chips: things you've run out of — one tap to restock to 'in'. + chips, seen = [], set() + for i in items: + if i.state == "out" and i.ingredient.name not in seen: + seen.add(i.ingredient.name) + chips.append({"name": i.ingredient.name, "location": i.location}) return { - "fridge_items": [i for i in items if i.location == "fridge"], - "freezer_items": [i for i in items if i.location == "freezer"], - "cupboard_items": [i for i in items if i.location == "cupboard"], + "groups": [ + {"key": "fridge", "label": "Fridge", "items": by_location("fridge")}, + {"key": "cupboard", "label": "Cupboard", "items": by_location("cupboard")}, + {"key": "freezer", "label": "Freezer", "items": by_location("freezer")}, + ], + "has_items": bool(items), + "n_in": sum(1 for i in items if i.state == "in"), + "n_low": sum(1 for i in items if i.state == "low"), + "n_out": sum(1 for i in items if i.state == "out"), + "chips": chips[:8], } @@ -54,9 +86,13 @@ def recipes_page(request): servings = 2 today = date.today() - # Build pantry lookup + # Build pantry lookup of what's present (anything not marked 'out'). + # Presence-based: having the ingredient at all satisfies a slot, regardless + # of amount. Full quantity-aware matching is deferred (see plan.md §3). pantry = {} - for item in PantryItem.objects.select_related("ingredient").filter(quantity__gt=0): + for item in PantryItem.objects.select_related("ingredient").exclude( + state=PantryItem.State.OUT + ): if item.ingredient_id not in pantry: pantry[item.ingredient_id] = [] pantry[item.ingredient_id].append({ @@ -67,7 +103,8 @@ def recipes_page(request): }) def get_total(ing_id): - return sum(p["quantity"] for p in pantry.get(ing_id, [])) + # Presence as availability: "have it" beats "have enough" for now. + return Decimal("Infinity") if ing_id in pantry else Decimal("0") def get_warnings(ing_id): warnings = [] @@ -187,46 +224,69 @@ def log_page(request): @csrf_exempt @require_POST def pantry_add(request): + """Add an item (or restock an existing one to 'in'). Quantity is optional.""" name = request.POST.get("ingredient_name", "").strip() - quantity = Decimal(request.POST.get("quantity", "0")) - unit = request.POST.get("unit", "items") location = request.POST.get("location", "fridge") - if not name: return HttpResponse("", status=400) ingredient = _find_ingredient(name) if not ingredient: - ingredient = Ingredient.objects.create( - name=name.lower(), - default_unit=unit, - ) + ingredient = Ingredient.objects.create(name=name.lower(), default_unit="items") + + unit = ingredient.default_unit or "items" + qty_raw = request.POST.get("quantity", "").strip() + quantity = int(qty_raw) if qty_raw.isdigit() else None expiry_date = None if location == "fridge" and ingredient.shelf_life_days: expiry_date = date.today() + timedelta(days=ingredient.shelf_life_days) - # Check for existing in same location - existing = PantryItem.objects.filter( - ingredient=ingredient, location=location, quantity__gt=0 - ).first() - + # Restock an existing row for this ingredient+location rather than duplicate. + existing = PantryItem.objects.filter(ingredient=ingredient, location=location).first() if existing: - existing.quantity += quantity + existing.state = PantryItem.State.IN + if quantity is not None: + existing.quantity = (existing.quantity or 0) + quantity if expiry_date: existing.expiry_date = expiry_date - existing.save(update_fields=["quantity", "expiry_date"]) + existing.save() else: PantryItem.objects.create( ingredient=ingredient, + state=PantryItem.State.IN, quantity=quantity, unit=unit, location=location, expiry_date=expiry_date, ) - ctx = _pantry_context() - return render(request, "kitchen/partials/pantry_table.html", ctx) + return render(request, "kitchen/partials/pantry_table.html", _pantry_context()) + + +@csrf_exempt +@require_POST +def pantry_set_state(request, item_id): + """Set an item's In/Low/Out state — the primary pantry interaction.""" + item = get_object_or_404(PantryItem, id=item_id) + to = request.POST.get("to", "") + if to in PantryItem.State.values: + item.state = to + item.save() + return render(request, "kitchen/partials/pantry_table.html", _pantry_context()) + + +def pantry_search(request): + """Autocomplete suggestions for the add box (name match, case-insensitive).""" + q = request.GET.get("ingredient_name", "").strip() + suggestions = ( + list(Ingredient.objects.filter(name__icontains=q).order_by("name")[:8]) if q else [] + ) + return render( + request, + "kitchen/partials/pantry_suggestions.html", + {"suggestions": suggestions, "q": q}, + ) @csrf_exempt @@ -241,53 +301,35 @@ def pantry_delete(request, item_id): @csrf_exempt @require_POST def pantry_move(request, item_id): - """Move item between fridge/freezer.""" + """Move an item between fridge / freezer / cupboard.""" 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 + item.expiry_date = None # frozen = no expiry elif target == "fridge": item.location = "fridge" - # Default +7 days when defrosting + # Default a fresh window when defrosting / moving into the fridge. 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) + elif target == "cupboard": + item.location = "cupboard" - 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}) + item.save() + return render(request, "kitchen/partials/pantry_table.html", _pantry_context()) @csrf_exempt @require_POST def pantry_save_expiry(request, item_id): - """Save edited expiry date.""" + """Set or clear an item's expiry date (inline editor in the item menu).""" 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) + item.expiry_date = request.POST.get("expiry_date") or None + item.save() + return render(request, "kitchen/partials/pantry_table.html", _pantry_context()) @csrf_exempt -- cgit v1.2.3