summaryrefslogtreecommitdiff
path: root/kitchen/views_htmx.py
diff options
context:
space:
mode:
Diffstat (limited to 'kitchen/views_htmx.py')
-rw-r--r--kitchen/views_htmx.py152
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