diff options
| author | Tom Flux <tom@tomflux.xyz> | 2026-06-23 19:55:16 +0100 |
|---|---|---|
| committer | Tom Flux <tom@tomflux.xyz> | 2026-06-23 19:55:16 +0100 |
| commit | 0aabf50834a31ff4ae9f8fd58639e444a449110b (patch) | |
| tree | 3c0033c8326268e098ebf4bac020d5a427ee3f9c /kitchen/views_htmx.py | |
| parent | e34081ce36d1993912dad514127924440437a2e9 (diff) | |
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 <noreply@anthropic.com>
Diffstat (limited to 'kitchen/views_htmx.py')
| -rw-r--r-- | kitchen/views_htmx.py | 152 |
1 files changed, 97 insertions, 55 deletions
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 |
