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 | |
| 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')
| -rw-r--r-- | kitchen/admin.py | 5 | ||||
| -rw-r--r-- | kitchen/helpers.py | 11 | ||||
| -rw-r--r-- | kitchen/management/commands/seed.py | 7 | ||||
| -rw-r--r-- | kitchen/migrations/0003_pantryitem_state.py | 42 | ||||
| -rw-r--r-- | kitchen/models.py | 16 | ||||
| -rw-r--r-- | kitchen/templates/kitchen/base.html | 1 | ||||
| -rw-r--r-- | kitchen/templates/kitchen/pantry.html | 366 | ||||
| -rw-r--r-- | kitchen/templates/kitchen/partials/pantry_suggestions.html | 21 | ||||
| -rw-r--r-- | kitchen/templates/kitchen/partials/pantry_table.html | 143 | ||||
| -rw-r--r-- | kitchen/tests.py | 106 | ||||
| -rw-r--r-- | kitchen/urls_htmx.py | 4 | ||||
| -rw-r--r-- | kitchen/views_htmx.py | 152 |
12 files changed, 681 insertions, 193 deletions
diff --git a/kitchen/admin.py b/kitchen/admin.py index 661c931..9f924ae 100644 --- a/kitchen/admin.py +++ b/kitchen/admin.py @@ -37,6 +37,7 @@ class IngredientAdmin(admin.ModelAdmin): class PantryItemAdmin(admin.ModelAdmin): list_display = [ "ingredient", + "state", "quantity", "unit", "location", @@ -44,9 +45,9 @@ class PantryItemAdmin(admin.ModelAdmin): "expiry_date", "is_staple", ] - list_filter = ["location", "is_staple"] + list_filter = ["state", "location", "is_staple"] search_fields = ["ingredient__name"] - list_editable = ["quantity"] + list_editable = ["state", "quantity"] # --- Meta-Recipe inlines --- diff --git a/kitchen/helpers.py b/kitchen/helpers.py index a4be79c..7853fd7 100644 --- a/kitchen/helpers.py +++ b/kitchen/helpers.py @@ -25,10 +25,15 @@ def find_ingredient(name): def get_pantry_total(ingredient_id): - """Get total quantity of an ingredient across all pantry locations.""" + """Total quantity of an ingredient across all locations, excluding items + marked 'out'. Null quantities count as 0 (presence is tracked via state, + not this number — see the pantry redesign in plan.md).""" total = Decimal("0") - for item in PantryItem.objects.filter(ingredient_id=ingredient_id, quantity__gt=0): - total += item.quantity + for item in ( + PantryItem.objects.filter(ingredient_id=ingredient_id) + .exclude(state=PantryItem.State.OUT) + ): + total += Decimal(item.quantity or 0) return total diff --git a/kitchen/management/commands/seed.py b/kitchen/management/commands/seed.py index 522e178..d79adf6 100644 --- a/kitchen/management/commands/seed.py +++ b/kitchen/management/commands/seed.py @@ -94,14 +94,15 @@ class Command(BaseCommand): # --- Pantry Items (from current Pantry.md as of 31 Mar 2026) --- self.stdout.write("Creating pantry items...") - def add_pantry(name, qty, unit, location, expiry=None, is_staple=False, notes=""): + def add_pantry(name, qty, unit, location, expiry=None, is_staple=False, notes="", state="in"): # Use ingredient + location for lookup so same ingredient can exist # in multiple locations (e.g. sausages in fridge AND freezer) PantryItem.objects.get_or_create( ingredient=ingredients[name], location=location, defaults={ - "quantity": Decimal(str(qty)), + "state": state, + "quantity": int(qty), "unit": unit, "expiry_date": expiry, "is_staple": is_staple, @@ -132,7 +133,7 @@ class Command(BaseCommand): add_pantry(name, 1, "n/a", "cupboard", is_staple=True) # Out of stock (for reference) - add_pantry("eggs", 0, "items", "fridge", notes="OUT — restock") + add_pantry("eggs", 0, "items", "fridge", notes="OUT — restock", state="out") add_pantry("baked beans", 1, "tins", "cupboard") add_pantry("freezer bags", 1, "boxes", "cupboard") diff --git a/kitchen/migrations/0003_pantryitem_state.py b/kitchen/migrations/0003_pantryitem_state.py new file mode 100644 index 0000000..a380a39 --- /dev/null +++ b/kitchen/migrations/0003_pantryitem_state.py @@ -0,0 +1,42 @@ +from django.db import migrations, models + + +def backfill_state(apps, schema_editor): + """Items at quantity 0 are 'out'; everything else is 'in' (the field default).""" + PantryItem = apps.get_model("kitchen", "PantryItem") + PantryItem.objects.filter(quantity=0).update(state="out") + + +class Migration(migrations.Migration): + + dependencies = [ + ("kitchen", "0002_add_rating_and_preferences"), + ] + + operations = [ + migrations.AddField( + model_name="pantryitem", + name="state", + field=models.CharField( + choices=[("in", "In stock"), ("low", "Running low"), ("out", "Out")], + default="in", + max_length=10, + ), + ), + # Runs while quantity is still a Decimal column — 0 matches fine. + migrations.RunPython(backfill_state, migrations.RunPython.noop), + migrations.AlterField( + model_name="pantryitem", + name="quantity", + field=models.PositiveIntegerField( + blank=True, + help_text="Optional rough count; In/Low/Out is the primary signal", + null=True, + ), + ), + migrations.AlterField( + model_name="pantryitem", + name="unit", + field=models.CharField(blank=True, max_length=20), + ), + ] diff --git a/kitchen/models.py b/kitchen/models.py index ab0a720..07bf231 100644 --- a/kitchen/models.py +++ b/kitchen/models.py @@ -50,9 +50,18 @@ class PantryItem(models.Model): FREEZER = "freezer", "Freezer" CUPBOARD = "cupboard", "Cupboard" + class State(models.TextChoices): + IN = "in", "In stock" + LOW = "low", "Running low" + OUT = "out", "Out" + ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE) - quantity = models.DecimalField(max_digits=8, decimal_places=2) - unit = models.CharField(max_length=20) + # In/Low/Out is the primary signal. quantity is an optional rough count. + state = models.CharField(max_length=10, choices=State.choices, default=State.IN) + quantity = models.PositiveIntegerField( + null=True, blank=True, help_text="Optional rough count; In/Low/Out is the primary signal" + ) + unit = models.CharField(max_length=20, blank=True) location = models.CharField(max_length=20, choices=Location.choices) stored_date = models.DateField(auto_now_add=True) expiry_date = models.DateField(null=True, blank=True) @@ -65,7 +74,8 @@ class PantryItem(models.Model): ordering = ["expiry_date", "ingredient__name"] def __str__(self): - return f"{self.ingredient.name} ({self.quantity} {self.unit}) [{self.location}]" + qty = f"{self.quantity} {self.unit}" if self.quantity is not None else self.get_state_display() + return f"{self.ingredient.name} ({qty}) [{self.location}]" def clean(self): """Business rules for pantry items.""" diff --git a/kitchen/templates/kitchen/base.html b/kitchen/templates/kitchen/base.html index 6bd33bf..323e985 100644 --- a/kitchen/templates/kitchen/base.html +++ b/kitchen/templates/kitchen/base.html @@ -398,6 +398,7 @@ td, th { padding: 0.35rem 0.5rem; font-size: 0.8rem; } } </style> + {% block extrastyle %}{% endblock %} </head> <body> <nav> diff --git a/kitchen/templates/kitchen/pantry.html b/kitchen/templates/kitchen/pantry.html index fac4d91..1f09db4 100644 --- a/kitchen/templates/kitchen/pantry.html +++ b/kitchen/templates/kitchen/pantry.html @@ -1,43 +1,343 @@ {% extends "kitchen/base.html" %} {% block title %}Pantry — Kitchen{% endblock %} +{% block extrastyle %} +<style> + /* leave room for the fixed bottom add bar */ + .container { padding-bottom: 9.5rem; } + + /* --- summary --- */ + .pantry-summary { + font-family: var(--mono, ui-monospace, Menlo, Consolas, monospace); + font-size: 0.74rem; + letter-spacing: 0.02em; + color: var(--sage); + display: flex; + gap: 1rem; + margin: 0.25rem 0 1rem; + } + .pantry-summary b { font-weight: 700; } + .pantry-summary .s-in b { color: var(--teal-light); } + .pantry-summary .s-low b { color: var(--orange-warm); } + .pantry-summary .s-out b { color: var(--red-bright); } + + /* --- group label --- */ + .pgroup-label { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--teal-light); + padding: 1.1rem 0.15rem 0.5rem; + display: flex; + align-items: center; + gap: 0.6rem; + } + .pgroup-label::after { + content: ""; + flex: 1; + height: 1px; + background: rgba(135, 209, 209, 0.14); + } + .pgroup-label .count { color: var(--sage); } + + /* --- item card: colored rail is the glance signal --- */ + .pitem { + position: relative; + background: #1d1a26; + border: 1px solid rgba(135, 209, 209, 0.14); + border-radius: 10px; + padding: 0.7rem 0.8rem 0.7rem 1rem; + margin-bottom: 0.55rem; + overflow: hidden; + transition: opacity 0.18s ease; + } + .pitem::before { + content: ""; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 5px; + background: var(--sage); + transition: background 0.18s ease; + } + .pitem[data-state="in"]::before { background: var(--teal-light); } + .pitem[data-state="low"]::before { background: var(--orange-warm); } + .pitem[data-state="out"]::before { background: var(--red-bright); } + .pitem[data-state="out"] { opacity: 0.55; } + + .pitem-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + } + .pitem-name { + font-size: 1.02rem; + font-weight: 600; + color: var(--cream); + line-height: 1.2; + } + .pitem-name .staple { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.58rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--yellow); + border: 1px solid rgba(249, 223, 17, 0.4); + border-radius: 3px; + padding: 0.05rem 0.3rem; + margin-left: 0.4rem; + vertical-align: middle; + } + + .pitem-bottom { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + margin-top: 0.6rem; + min-height: 40px; + } + .pitem .meta { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.74rem; + color: var(--sage); + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + } + .pitem .pill { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.66rem; + padding: 0.1rem 0.4rem; + border-radius: 4px; + } + .pill.soon { color: var(--orange-warm); background: rgba(250, 159, 69, 0.16); } + .pill.gone { color: var(--red-bright); background: rgba(240, 17, 17, 0.16); } + .pill.frozen { color: var(--teal-light); background: rgba(135, 209, 209, 0.12); } + .pill.ok { color: var(--sage); background: rgba(101, 141, 137, 0.16); } + + /* --- the hero: segmented state switch --- */ + .segctl { + display: inline-flex; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(135, 209, 209, 0.14); + border-radius: 8px; + padding: 2px; + flex-shrink: 0; + } + .seg { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--sage); + background: none; + border: none; + cursor: pointer; + min-width: 44px; + min-height: 36px; + padding: 0 0.5rem; + border-radius: 6px; + transition: background 0.14s ease, color 0.14s ease; + } + .seg:active { transform: translateY(1px); } + .pitem[data-state="in"] .seg[data-v="in"] { background: var(--teal-light); color: var(--bg-dark); } + .pitem[data-state="low"] .seg[data-v="low"] { background: var(--orange-warm); color: var(--bg-dark); } + .pitem[data-state="out"] .seg[data-v="out"] { background: var(--red-bright); color: var(--cream); } + + /* --- per-item menu (move / expiry / delete) --- */ + .menu { position: relative; } + .menu .kebab { + list-style: none; + cursor: pointer; + color: var(--sage); + font-size: 1.3rem; + line-height: 1; + padding: 0.2rem 0.45rem; + border-radius: 6px; + user-select: none; + } + .menu .kebab::-webkit-details-marker { display: none; } + .menu[open] .kebab { color: var(--cream); background: rgba(255, 255, 255, 0.05); } + .menu-pop { + position: absolute; + right: 0; + top: 2rem; + z-index: 40; + background: #221f2d; + border: 1px solid var(--teal-dark); + border-radius: 10px; + padding: 0.4rem; + min-width: 11rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 0.15rem; + } + .menu-act { + text-align: left; + background: none; + border: none; + color: var(--grey-light); + font-size: 0.85rem; + padding: 0.5rem 0.6rem; + border-radius: 6px; + cursor: pointer; + } + .menu-act:hover { background: rgba(135, 209, 209, 0.1); color: var(--cream); } + .menu-act.danger { color: var(--red-bright); } + .menu-act.danger:hover { background: rgba(240, 17, 17, 0.14); } + .exp-form { + display: flex; + gap: 0.3rem; + padding: 0.35rem 0.6rem 0.2rem; + border-top: 1px solid rgba(135, 209, 209, 0.12); + margin-top: 0.15rem; + } + .exp-form input[type="date"] { font-size: 0.78rem; padding: 0.3rem 0.4rem; flex: 1; } + .exp-form button { font-size: 0.78rem; padding: 0.3rem 0.55rem; } + + /* --- bottom add bar (thumb zone) --- */ + .addbar { + position: fixed; + left: 0; right: 0; bottom: 0; + z-index: 60; + background: linear-gradient(rgba(21, 19, 28, 0), var(--bg-dark) 30%); + padding-top: 1.6rem; + } + .addbar-inner { + max-width: 960px; + margin: 0 auto; + padding: 0 1.5rem 1rem; + } + .chips { + display: flex; + gap: 0.4rem; + overflow-x: auto; + padding-bottom: 0.55rem; + scrollbar-width: none; + } + .chips::-webkit-scrollbar { display: none; } + .chip { + font-family: ui-monospace, Menlo, Consolas, monospace; + font-size: 0.74rem; + white-space: nowrap; + color: var(--peach); + background: rgba(189, 102, 17, 0.16); + border: 1px solid rgba(250, 159, 69, 0.28); + border-radius: 999px; + padding: 0.34rem 0.7rem; + cursor: pointer; + flex-shrink: 0; + } + .chip:active { transform: translateY(1px); } + .chip .plus { color: var(--orange-warm); font-weight: 700; margin-right: 0.15rem; } + + .suggestions { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 0.55rem; } + .suggestions:empty { display: none; } + .suggestion { + font-size: 0.85rem; + color: var(--cream); + background: rgba(50, 86, 100, 0.3); + border: 1px solid var(--teal-dark); + border-radius: 8px; + padding: 0.45rem 0.7rem; + cursor: pointer; + } + .suggestion:hover { border-color: var(--yellow); } + .suggestion.new { color: var(--yellow); border-style: dashed; } + + .addrow { display: flex; gap: 0.5rem; } + .addrow select { + min-height: 48px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--teal-dark); + color: var(--cream); + font-size: 0.9rem; + padding: 0 0.5rem; + } + .addrow input[type="text"] { + flex: 1; + min-height: 48px; + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--teal-dark); + color: var(--cream); + font-size: 0.95rem; + padding: 0.7rem 0.9rem; + border-radius: 10px; + } + .addrow input[type="text"]::placeholder { color: var(--sage); } + .addrow input[type="text"]:focus { + outline: none; + border-color: var(--yellow); + box-shadow: 0 0 0 2px rgba(249, 223, 17, 0.16); + } + .add-btn { + background: var(--yellow); + color: var(--bg-dark); + border: none; + font-size: 1.5rem; + font-weight: 700; + width: 48px; + min-height: 48px; + border-radius: 10px; + cursor: pointer; + flex-shrink: 0; + line-height: 1; + } + .add-btn:active { transform: translateY(1px); } + + @media (prefers-reduced-motion: reduce) { + .pitem, .pitem::before, .seg { transition: none; } + } +</style> +{% endblock %} + {% block content %} <h1>Pantry</h1> -<!-- Add item form --> -<div class="card" style="margin-bottom: 1.5rem;"> - <form hx-post="{% url 'app-pantry-add' %}" hx-target="#pantry-items" hx-swap="innerHTML" hx-on::after-request="if(event.detail.successful) this.reset()"> - {% csrf_token %} - <div class="form-row"> - <div class="form-group" style="flex: 2;"> - <label for="ingredient_name">Ingredient</label> - <input type="text" id="ingredient_name" name="ingredient_name" placeholder="e.g. chicken thighs" required style="width: 100%;"> - </div> - <div class="form-group"> - <label for="quantity">Qty</label> - <input type="number" id="quantity" name="quantity" step="0.01" placeholder="2" required style="width: 100%;"> - </div> - <div class="form-group"> - <label for="unit">Unit</label> - <input type="text" id="unit" name="unit" placeholder="items" required style="width: 100%;"> - </div> - <div class="form-group"> - <label for="location">Location</label> - <select id="location" name="location" style="width: 100%;"> - <option value="fridge">Fridge</option> - <option value="freezer">Freezer</option> - <option value="cupboard">Cupboard</option> - </select> - </div> - <div class="form-group" style="display: flex; align-items: flex-end;"> - <button type="submit" class="btn btn-primary">Add</button> - </div> - </div> - </form> -</div> - -<!-- Pantry items --> <div id="pantry-items"> {% include "kitchen/partials/pantry_table.html" %} </div> + +<div class="addbar"> + <div class="addbar-inner"> + {% if chips %} + <div class="chips"> + {% for c in chips %} + <button class="chip" type="button" + hx-post="{% url 'app-pantry-add' %}" + hx-vals='{"ingredient_name": "{{ c.name|escapejs }}", "location": "{{ c.location }}"}' + hx-target="#pantry-items" hx-swap="innerHTML"> + <span class="plus">+</span>{{ c.name }} + </button> + {% endfor %} + </div> + {% endif %} + + <div id="add-suggestions" class="suggestions"></div> + + <div class="addrow"> + <select id="add-location" name="location" aria-label="Location"> + <option value="fridge">Fridge</option> + <option value="cupboard">Cupboard</option> + <option value="freezer">Freezer</option> + </select> + <input type="text" id="add-input" name="ingredient_name" placeholder="Add something…" + autocomplete="off" + hx-get="{% url 'app-pantry-search' %}" + hx-trigger="keyup changed delay:200ms" + hx-target="#add-suggestions" hx-swap="innerHTML"> + <button class="add-btn" aria-label="Add" + hx-post="{% url 'app-pantry-add' %}" + hx-include="#add-input, #add-location" + hx-target="#pantry-items" hx-swap="innerHTML" + hx-on::after-request="document.getElementById('add-input').value=''; document.getElementById('add-suggestions').innerHTML='';">+</button> + </div> + </div> +</div> {% endblock %} diff --git a/kitchen/templates/kitchen/partials/pantry_suggestions.html b/kitchen/templates/kitchen/partials/pantry_suggestions.html new file mode 100644 index 0000000..4ff2048 --- /dev/null +++ b/kitchen/templates/kitchen/partials/pantry_suggestions.html @@ -0,0 +1,21 @@ +{% for s in suggestions %} +<button type="button" class="suggestion" + hx-post="{% url 'app-pantry-add' %}" + hx-vals='{"ingredient_name": "{{ s.name|escapejs }}"}' + hx-include="#add-location" + hx-target="#pantry-items" hx-swap="innerHTML" + hx-on::after-request="document.getElementById('add-input').value=''; document.getElementById('add-suggestions').innerHTML='';"> + {{ s.name }} +</button> +{% empty %} +{% if q %} +<button type="button" class="suggestion new" + hx-post="{% url 'app-pantry-add' %}" + hx-vals='{"ingredient_name": "{{ q|escapejs }}"}' + hx-include="#add-location" + hx-target="#pantry-items" hx-swap="innerHTML" + hx-on::after-request="document.getElementById('add-input').value=''; document.getElementById('add-suggestions').innerHTML='';"> + + Add “{{ q }}” +</button> +{% endif %} +{% endfor %} diff --git a/kitchen/templates/kitchen/partials/pantry_table.html b/kitchen/templates/kitchen/partials/pantry_table.html index 4a8db02..c9b5674 100644 --- a/kitchen/templates/kitchen/partials/pantry_table.html +++ b/kitchen/templates/kitchen/partials/pantry_table.html @@ -1,97 +1,60 @@ -{% if fridge_items or freezer_items or cupboard_items %} - -{% if fridge_items %} -<h2>🧊 Fridge</h2> -<table> - <thead><tr><th>Item</th><th>Qty</th><th>Expiry</th><th></th></tr></thead> - <tbody> - {% for item in fridge_items %} - <tr id="pantry-row-{{ item.id }}"> - <td>{{ item.ingredient.name }}</td> - <td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td> - <td> - {% if item.is_expired %} - <span class="badge badge-expired">Expired {{ item.expiry_date }}</span> - {% elif item.expiring_soon %} - <span class="badge badge-expiring">{{ item.expiry_date }}</span> - {% elif item.expiry_date %} - <span class="badge badge-ok">{{ item.expiry_date }}</span> - {% else %} - <span style="color: var(--sage);">—</span> - {% endif %} - <button class="btn btn-sm btn-secondary" style="margin-left: 0.25rem; padding: 0.1rem 0.3rem; font-size: 0.7rem;" - hx-post="{% url 'app-pantry-edit-expiry' item.id %}" - hx-target="#pantry-row-{{ item.id }}" - hx-swap="outerHTML" - title="Change expiry">📅</button> - </td> - <td style="text-align: right; white-space: nowrap;"> - <button class="btn btn-sm btn-secondary" style="padding: 0.1rem 0.3rem; font-size: 0.7rem;" - hx-post="{% url 'app-pantry-move' item.id %}" - hx-target="#pantry-items" - hx-vals='{"to": "freezer"}' - title="Move to freezer">❄️</button> - <button class="btn btn-danger btn-sm" - hx-delete="{% url 'app-pantry-delete' item.id %}" - hx-target="#pantry-items" - hx-confirm="Remove {{ item.ingredient.name }}?">✕</button> - </td> - </tr> - {% endfor %} - </tbody> -</table> -{% endif %} - -{% if freezer_items %} -<h2>❄️ Freezer</h2> -<table> - <thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead> - <tbody> - {% for item in freezer_items %} - <tr id="pantry-row-{{ item.id }}"> - <td>{{ item.ingredient.name }}</td> - <td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td> - <td style="text-align: right; white-space: nowrap;"> - <button class="btn btn-sm btn-secondary" style="padding: 0.1rem 0.3rem; font-size: 0.7rem;" - hx-post="{% url 'app-pantry-move' item.id %}" - hx-target="#pantry-items" - hx-vals='{"to": "fridge"}' - title="Defrost → Fridge">🧊→</button> - <button class="btn btn-danger btn-sm" - hx-delete="{% url 'app-pantry-delete' item.id %}" - hx-target="#pantry-items" - hx-confirm="Remove {{ item.ingredient.name }}?">✕</button> - </td> - </tr> - {% endfor %} - </tbody> -</table> -{% endif %} +<div class="pantry-summary"> + <span class="s-in"><b>{{ n_in }}</b> in stock</span> + <span class="s-low"><b>{{ n_low }}</b> running low</span> + <span class="s-out"><b>{{ n_out }}</b> out</span> +</div> -{% if cupboard_items %} -<h2>🗄️ Cupboard</h2> -<table> - <thead><tr><th>Item</th><th>Qty</th><th></th></tr></thead> - <tbody> - {% for item in cupboard_items %} - <tr id="pantry-row-{{ item.id }}"> - <td>{{ item.ingredient.name }}{% if item.is_staple %} <span style="color: var(--yellow); font-size: 0.7rem;">STAPLE</span>{% endif %}</td> - <td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td> - <td style="text-align: right;"> - <button class="btn btn-danger btn-sm" - hx-delete="{% url 'app-pantry-delete' item.id %}" - hx-target="#pantry-items" - hx-confirm="Remove {{ item.ingredient.name }}?">✕</button> - </td> - </tr> +{% if has_items %} + {% for group in groups %} + {% if group.items %} + <div class="pgroup-label">{{ group.label }} <span class="count">{{ group.items|length }}</span></div> + {% for item in group.items %} + <div class="pitem" data-state="{{ item.state }}"> + <div class="pitem-top"> + <div class="pitem-name">{{ item.ingredient.name }}{% if item.is_staple %}<span class="staple">staple</span>{% endif %}</div> + <details class="menu"> + <summary class="kebab" aria-label="More actions">⋯</summary> + <div class="menu-pop"> + {% if item.location != "fridge" %} + <button class="menu-act" hx-post="{% url 'app-pantry-move' item.id %}" hx-vals='{"to": "fridge"}' hx-target="#pantry-items" hx-swap="innerHTML">Move to fridge</button> + {% endif %} + {% if item.location != "cupboard" %} + <button class="menu-act" hx-post="{% url 'app-pantry-move' item.id %}" hx-vals='{"to": "cupboard"}' hx-target="#pantry-items" hx-swap="innerHTML">Move to cupboard</button> + {% endif %} + {% if item.location != "freezer" %} + <button class="menu-act" hx-post="{% url 'app-pantry-move' item.id %}" hx-vals='{"to": "freezer"}' hx-target="#pantry-items" hx-swap="innerHTML">Move to freezer</button> + {% endif %} + {% if item.location != "freezer" %} + <form class="exp-form" hx-post="{% url 'app-pantry-save-expiry' item.id %}" hx-target="#pantry-items" hx-swap="innerHTML"> + <input type="date" name="expiry_date" value="{{ item.expiry_date|date:'Y-m-d' }}"> + <button type="submit" class="menu-act" style="background: var(--teal-dark);">Set</button> + </form> + {% endif %} + <button class="menu-act danger" hx-delete="{% url 'app-pantry-delete' item.id %}" hx-target="#pantry-items" hx-swap="innerHTML" hx-confirm="Remove {{ item.ingredient.name }}?">Delete</button> + </div> + </details> + </div> + <div class="pitem-bottom"> + <div class="meta"> + {% if item.quantity %}<span>{{ item.quantity }} {{ item.unit }}</span>{% endif %} + {% if item.location == "freezer" %}<span class="pill frozen">frozen</span> + {% elif item.is_expired %}<span class="pill gone">expired</span> + {% elif item.expiring_soon %}<span class="pill soon">use in {{ item.days_left }}d</span> + {% elif item.expiry_date %}<span class="pill ok">{{ item.expiry_date|date:'j M' }}</span>{% endif %} + </div> + <div class="segctl"> + <button class="seg" data-v="in" hx-post="{% url 'app-pantry-set-state' item.id %}" hx-vals='{"to": "in"}' hx-target="#pantry-items" hx-swap="innerHTML">In</button> + <button class="seg" data-v="low" hx-post="{% url 'app-pantry-set-state' item.id %}" hx-vals='{"to": "low"}' hx-target="#pantry-items" hx-swap="innerHTML">Low</button> + <button class="seg" data-v="out" hx-post="{% url 'app-pantry-set-state' item.id %}" hx-vals='{"to": "out"}' hx-target="#pantry-items" hx-swap="innerHTML">Out</button> + </div> + </div> + </div> {% endfor %} - </tbody> -</table> -{% endif %} - + {% endif %} + {% endfor %} {% else %} <div class="empty"> <div class="empty-icon">🥡</div> - <p>Pantry's empty. Add some items above.</p> + <p>Pantry's empty. Add something below.</p> </div> {% endif %} diff --git a/kitchen/tests.py b/kitchen/tests.py index 7ce503c..31e0665 100644 --- a/kitchen/tests.py +++ b/kitchen/tests.py @@ -1,3 +1,105 @@ -from django.test import TestCase +from django.test import TestCase, Client +from django.urls import reverse -# Create your tests here. +from kitchen.models import Ingredient, PantryItem, MetaRecipe, Slot, SlotOption + + +class PantryStateTests(TestCase): + def setUp(self): + self.client = Client() + self.eggs = Ingredient.objects.create(name="eggs", default_unit="items") + self.item = PantryItem.objects.create( + ingredient=self.eggs, location="fridge", state="in" + ) + + def test_state_defaults_to_in(self): + self.assertEqual(self.item.state, "in") + + def test_quantity_is_optional(self): + self.assertIsNone(self.item.quantity) + + def test_set_state_endpoint(self): + resp = self.client.post( + reverse("app-pantry-set-state", args=[self.item.id]), {"to": "low"} + ) + self.assertEqual(resp.status_code, 200) + self.item.refresh_from_db() + self.assertEqual(self.item.state, "low") + + def test_set_state_rejects_unknown_value(self): + self.client.post( + reverse("app-pantry-set-state", args=[self.item.id]), {"to": "bogus"} + ) + self.item.refresh_from_db() + self.assertEqual(self.item.state, "in") # unchanged + + def test_out_items_still_listed(self): + self.item.state = "out" + self.item.save() + resp = self.client.get(reverse("app-pantry")) + self.assertContains(resp, "eggs") + + def test_add_creates_in_stock_item(self): + resp = self.client.post( + reverse("app-pantry-add"), + {"ingredient_name": "milk", "location": "fridge"}, + ) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + PantryItem.objects.filter(ingredient__name="milk", state="in").exists() + ) + + def test_add_restocks_existing_to_in(self): + self.item.state = "out" + self.item.save() + self.client.post( + reverse("app-pantry-add"), + {"ingredient_name": "eggs", "location": "fridge"}, + ) + # No duplicate row, and the existing one is back in stock. + items = PantryItem.objects.filter(ingredient=self.eggs, location="fridge") + self.assertEqual(items.count(), 1) + self.assertEqual(items.first().state, "in") + + def test_search_matches_by_name(self): + resp = self.client.get( + reverse("app-pantry-search"), {"ingredient_name": "egg"} + ) + self.assertContains(resp, "eggs") + + +class RecipesPresenceTests(TestCase): + """The recipes matcher is presence-based now: having an ingredient (not + 'out') satisfies a slot regardless of quantity.""" + + def setUp(self): + self.client = Client() + self.noodles = Ingredient.objects.create(name="noodles", default_unit="nests") + self.mr = MetaRecipe.objects.create(name="Noodles", method="boil") + slot = Slot.objects.create(meta_recipe=self.mr, name="carb", required=True) + SlotOption.objects.create( + slot=slot, ingredient=self.noodles, quantity_per_serving=2, unit="nests" + ) + + def test_present_ingredient_no_quantity_is_available(self): + # 'in' with no quantity should still count as available. + PantryItem.objects.create( + ingredient=self.noodles, location="cupboard", state="in" + ) + resp = self.client.get(reverse("app-recipes")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Noodles") + + def test_out_ingredient_renders_without_error(self): + PantryItem.objects.create( + ingredient=self.noodles, location="cupboard", state="out" + ) + resp = self.client.get(reverse("app-recipes")) + self.assertEqual(resp.status_code, 200) + + +class PageSmokeTests(TestCase): + def test_pages_render(self): + for name in ("app-pantry", "app-recipes", "app-shopping", "app-log"): + with self.subTest(page=name): + self.assertEqual(self.client.get(reverse(name)).status_code, 200) diff --git a/kitchen/urls_htmx.py b/kitchen/urls_htmx.py index 5c11447..1feb76c 100644 --- a/kitchen/urls_htmx.py +++ b/kitchen/urls_htmx.py @@ -10,11 +10,11 @@ urlpatterns = [ # HTMX partials — pantry path("pantry/add/", views_htmx.pantry_add, name="app-pantry-add"), + path("pantry/search/", views_htmx.pantry_search, name="app-pantry-search"), + path("pantry/<int:item_id>/state/", views_htmx.pantry_set_state, name="app-pantry-set-state"), path("pantry/<int:item_id>/delete/", views_htmx.pantry_delete, name="app-pantry-delete"), path("pantry/<int:item_id>/move/", views_htmx.pantry_move, name="app-pantry-move"), - path("pantry/<int:item_id>/edit-expiry/", views_htmx.pantry_edit_expiry, name="app-pantry-edit-expiry"), path("pantry/<int:item_id>/save-expiry/", views_htmx.pantry_save_expiry, name="app-pantry-save-expiry"), - path("pantry/cancel-edit/", views_htmx.pantry_cancel_edit, name="app-pantry-cancel-edit"), # HTMX partials — shopping path("shopping/generate/", views_htmx.shopping_generate, name="app-shopping-generate"), 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 |
