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>
This commit is contained in:
Tom Flux
2026-06-23 19:55:16 +01:00
co-authored by Claude Opus 4.8
parent 25a09b08fc
commit ce61a0a86f
12 changed files with 681 additions and 193 deletions
+97 -55
View File
@@ -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