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:
co-authored by
Claude Opus 4.8
parent
25a09b08fc
commit
ce61a0a86f
+97
-55
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user