Add planning docs: research, requirements, redesign plan

Research of current state, requirements for the web-first pivot
(pantry polish -> auth -> MCP), and the phased implementation plan.

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 173e74b4a9
commit 25a09b08fc
4 changed files with 754 additions and 17 deletions
@@ -1,17 +0,0 @@
<tr id="pantry-row-{{ item.id }}">
<td>{{ item.ingredient.name }}</td>
<td>{{ item.quantity|floatformat:0 }} {{ item.unit }}</td>
<td colspan="2">
<form style="display: flex; gap: 0.4rem; align-items: center;"
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' }}" style="font-size: 0.8rem;">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-secondary btn-sm"
hx-get="{% url 'app-pantry-cancel-edit' %}"
hx-target="#pantry-items"
hx-swap="innerHTML">Cancel</button>
</form>
</td>
</tr>
+231
View File
@@ -0,0 +1,231 @@
# Implementation Plan
> Written 2026-06-22. Turns `requirements.md` into concrete, sequenced work.
> Reflects the current priority order: **pantry polish → auth → MCP**, with the
> MCP being the highest-value piece (Claude reads *and updates* the pantry, so
> manual upkeep mostly goes away). Builds on the completed `/simplify` pass
> (shared `kitchen/helpers.py`, dead config removed).
>
> Altitude: file/model/endpoint level. Not code — decisions and steps.
## Sequencing at a glance
| Phase | Goal | Risk | Depends on |
|------|------|------|-----------|
| 0 | ✅ Commit the simplify baseline — **done** | none | — |
| 1 | Pantry: mobile-first, In/Low/Out, fast add | medium (model + UI) | 0 |
| 2 | Auth (login-once) + prod baseline | low | — (can run parallel to 1) |
| 3 | MCP server — read + update pantry from claude.ai | medium | 1, 2 |
Deferred (not in this plan): web what-can-i-cook rework, §4.1 substitutions,
shopping flow rethink, web cook-logging. They stay roughly working but are not
invested in here.
---
## Phase 0 — Commit the baseline ✅ done
Tom has committed the simplify baseline. Starting point is clean.
---
## Phase 1 — Pantry polish (mobile-first)
The heart of the friction problem. Move from "spreadsheet of decimals" to
"glance into the fridge", and rebuild the screen phone-first.
### 1.1 Model change — add presence state
`PantryItem` today: `quantity` (required Decimal), `unit`, `location`,
`expiry_date`, `is_staple`.
- [ ] Add `state` to `PantryItem`: `CharField(choices=IN/LOW/OUT, default="in")`.
New `TextChoices` like the existing `Location`.
- [ ] Change `quantity` from `DecimalField` to an **optional integer**
(`PositiveIntegerField(null=True, blank=True)`) — never going to have
1.234 pieces of toast. Keep `unit` too, but neither is required on add.
(Migration handles the Decimal→Integer + nullable change plus the
backfill below in one step.)
- [ ] Migration `0003_pantryitem_state_optional_quantity`. Data migration:
backfill existing rows → `state = "out"` where `quantity == 0`, else `"in"`.
- [ ] Decide the In/Low/Out ↔ legacy `quantity==0` / `is_staple` mapping in one
place (see 1.5 — it ripples into matcher + shopping queries).
> **Decided:** quantity becomes an optional **integer** (not a decimal, not
> free text). It's secondary metadata; In/Low/Out is the primary signal.
### 1.2 Presence filtering — stop relying on `quantity__gt=0`
Several queries use `quantity__gt=0` to mean "I have this". With optional
quantity that breaks (null ≯ 0). Replace presence checks with state.
- [ ] `_pantry_context` (`views_htmx.py`): show **all** items, don't filter out
depleted ones — "Out" rows render greyed (requirements §2.1). Annotate
`is_expired` / `expiring_soon` as today.
- [ ] `helpers.get_pantry_total` and the matcher's presence logic
(`what_can_i_cook`, `recipes_page`): treat `state != "out"` as "have it";
tolerate `quantity = None`. Minimal change only — full presence-based
matcher rework stays **deferred**. Goal here is just "don't crash / don't
silently show everything as missing."
### 1.3 Pantry UI — mobile-first rebuild
> **Decided:** the current design is poor — do a proper rethink of the pantry
> screen (layout, interaction, hierarchy), but **keep the palette** (Tom likes
> it). Mockup goes first (1.3.0) before touching Django templates.
**1.3.0 Design mockup first.** Produce a self-contained HTML mockup of the
redesigned pantry (phone width, real palette) as an Artifact Tom can open on
his phone and react to. Only wire it into Django templates once the look is
agreed.
Replace the table-of-tiny-links (`partials/pantry_table.html`) with a
phone-first card list.
- [ ] Per-item **card** (single column): ingredient name, location badge, and a
**3-way In / Low / Out button group** as the primary control — proper
buttons, ≥44px tap targets, not inline links. Expiry shown only if set, as
a soft "use soon" pill.
- [ ] Grouping: fridge / freezer / cupboard sections, collapsible or just
stacked. Keep it one-column; no horizontal scroll.
- [ ] State change endpoint: `POST /app/pantry/<id>/state/` (`to=in|low|out`),
re-renders the list partial via HTMX. Replaces the quantity-edit flow as
the main interaction.
- [ ] Keep delete and fridge↔freezer move, but as secondary (smaller, maybe
behind a tap). Expiry edit becomes optional, de-emphasised.
### 1.4 Fast add
- [ ] **Autocomplete**: `GET /app/pantry/search/?q=` returns matching
`Ingredient`s (name + aliases), rendered as an HTMX dropdown under the add
box. Tap a result → adds with sensible defaults (unit/location from the
`Ingredient`, `state="in"`, no qty required).
- [ ] **Quick-add chips**: render a row of one-tap chips from staples +
recently-used ingredients (recent = distinct ingredients from recent
`PantryItem`/`CookLog`). One tap adds/sets to In.
- [ ] **Add bar placement**: sticky to the **bottom** of the viewport (thumb
reach) on mobile, with the chips just above it. This is the make-or-break
detail per requirements.
- [ ] `pantry_add` already auto-creates unknown ingredients and merges into an
existing same-location row — keep that, but set `state="in"` on
add/merge, and stop requiring quantity.
### 1.5 CSS / layout (base.html)
- [ ] Shift `base.html` to mobile-first: the current `max-width: 960px` desktop
table styling becomes the *enhancement*; default styles target a narrow
phone screen. Bigger touch targets, larger tap-friendly buttons, bottom
nav or bottom add bar.
- [ ] Keep the existing dark palette (it's fine) — this is layout/sizing, not a
re-theme.
- [ ] Verify on a real phone-width viewport (e.g. 390px) — no horizontal
scroll, controls reachable one-handed.
### 1.6 Tests
`kitchen/tests.py` is empty. Add the first real tests here:
- [ ] Model: state defaults, optional quantity, backfill migration sanity.
- [ ] Views: add via autocomplete sets `state="in"`; state toggle endpoint;
pantry context includes "Out" rows.
---
## Phase 2 — Auth (login once) + prod baseline
Low risk, can proceed alongside Phase 1. Closes the wide-open `/app/`.
### 2.1 Session auth, long-lived
- [ ] One real user account (document the `createsuperuser` / set-password
step; don't commit credentials).
- [ ] Settings:
- `SESSION_COOKIE_AGE = 60*60*24*365` (~1 year)
- `SESSION_EXPIRE_AT_BROWSER_CLOSE = False`
- `SESSION_SAVE_EVERY_REQUEST = True` (sliding expiry — each visit renews
the year, so an active user is effectively never logged out)
- `LOGIN_URL` / `LOGIN_REDIRECT_URL`
- [ ] Login page: `django.contrib.auth.views.LoginView` + a minimal template in
the existing dark style. Logout link somewhere unobtrusive.
- [ ] Protect `/app/`: simplest is Django 5.x `LoginRequiredMiddleware`, but it
hits *every* view — so exempt `/api/` (token auth keeps its own) and the
login view. If exemptions get fiddly, fall back to wrapping the
`urls_htmx` includes / decorating the page views with `login_required`.
**Pick one approach; don't mix.**
### 2.2 Drop the CSRF hacks
- [ ] With a real session + CSRF token (already wired in `base.html`), remove
every `@csrf_exempt` from `views_htmx.py`. Verify HTMX POST/DELETE still
work (the `htmx:configRequest` handler already sends `X-CSRFToken`).
### 2.3 Production baseline (cheap, fold in here)
- [ ] `DEBUG` from env (default False in prod).
- [ ] `SECRET_KEY` from env; drop the committed `django-insecure-…` literal.
- [ ] Keep `ALLOWED_HOSTS` as-is.
---
## Phase 3 — MCP server (the goal)
Let claude.ai "cooking mode" read the pantry and write changes back, over the
existing `/api/` (token auth, `IsAuthenticated`). This is what kills the
"recite my whole pantry every time" chore.
### 3.1 Shape
- [ ] **Separate FastMCP service** (decided). Mirrors a pattern Tom already
runs elsewhere: a standalone "ai service" FastMCP server that talks to the
Django API with the `caine` token. Keeps concerns decoupled; the API is
already the right interface and the `/simplify` pass left it clean.
- [ ] Tools to expose (map to existing endpoints):
- `get_pantry``GET /api/pantry/` (+ state)
- `add_to_pantry``POST /api/bulk-pantry-add/`
- `set_pantry_state` → new tiny endpoint or `PATCH /api/pantry/<id>/`
setting `state` (in/low/out) — the conversational update path
("used the last of the noodles" → set Out)
- `what_can_i_cook``GET /api/what-can-i-cook/`
- `log_cook``POST /api/log-cook/` (and make it accept `rating`, the
one API gap from research §5)
- [ ] Add the `state` field to the pantry serializer so MCP reads/writes it.
### 3.2 Transport, hosting, auth — **decided**
- **Transport:** remote MCP over HTTP (streamable HTTP / SSE), reachable at
**`food.jihakuz.xyz/mcp`**. Not stdio.
- **Hosting:** same box as Django, behind nginx at `/mcp`, as its own systemd
service alongside `food.service`.
- **Auth:** **bearer token** — single user, so one long high-entropy token
checked by the MCP service (and/or nginx) is enough. It must be tight: this
endpoint is internet-exposed (unlike `/api/` and `/admin/`, which nginx
blocks externally), so the token is the only thing protecting it. Strong
token, kept out of git (env/secret), TLS only.
### 3.3 Why the cooking blocks still work (no action, just the rationale)
MCP only feeds data/tools into the claude.ai conversation; it doesn't change
how cooking mode renders. So Claude pulls real pantry contents *and* still
produces its formatted cooking blocks — grounded instead of guessed.
---
## Decisions (locked)
1. **Quantity** → optional **integer**, secondary to In/Low/Out. ✅
2. **MCP** → FastMCP "ai service" on the same box at `food.jihakuz.xyz/mcp`,
bearer-token auth, talks to the Django API. ✅
3. **Design** → rethink the pantry screen, keep the palette; mockup first. ✅
Still just an implementation detail (not a blocker): **auth enforcement**
`LoginRequiredMiddleware` + exemptions vs per-view `login_required`. Plan: try
middleware, fall back if exemptions get fiddly.
## Definition of done (per phase)
- **P1:** Tom can add an item and flip In/Low/Out on his phone in a couple of
taps, one-handed, no horizontal scroll; pantry stays glanceable.
- **P2:** Visiting `/app/` requires login; logging in once sticks for ~a year;
no `@csrf_exempt` left; `DEBUG=False` + env `SECRET_KEY` in prod.
- **P3:** In claude.ai cooking mode, Claude can list the pantry and mark items
In/Low/Out (and log a cook) without Tom typing the inventory.
+260
View File
@@ -0,0 +1,260 @@
# Food App — Requirements (pivot to web-first)
> Written 2026-06-22, after reviewing `research.md` and interviewing Tom.
> This supersedes the Caine-centric design. Pairs with `research.md` (current
> state) — this doc is the *target*.
## The pivot, in one line
Caine (the Matrix LLM assistant) was never really used — it cost too much per
call. **The web UI becomes the primary interface.** An LLM can come back later
as an MCP server riding on the existing claude.ai subscription (no per-call
cost), but that's a later phase, not now.
## The one constraint that wins
**Ease of use beats everything.** This is a single-user personal app, low
stakes. When a requirement trades correctness, completeness, or robustness for
less friction, friction wins. Specifically: fewer fields, fewer taps, fewer
decisions, nothing that nags, and updating the pantry should feel like nothing.
**Mobile-first, not mobile-also.** The pantry is used standing in the kitchen
or at the shop, on a phone — that's the primary device, and it's fiddly today.
Design for a one-handed phone screen first; desktop is the afterthought.
Concretely: big tap targets (state changes and add are thumb-sized, not
table-cell links), no tiny number inputs or cramped rows, single-column
layouts, no horizontal scroll, controls reachable in the lower half of the
screen. If something works on the phone it'll be fine on desktop; the reverse
is what's been failing.
Everything below is judged against that.
---
## Priorities (Tom, 2026-06-22 — current call)
In value order:
1. **Pantry: make it a bit nicer in the web UI.** Not a ground-up rebuild —
incremental polish (the In/Low/Out state + fast add from §2), enough that
keeping it accurate isn't a slog. It needs to be *reasonably* accurate
precisely because of #3.
2. **Add some auth** (§1) — login once, long session.
3. **MCP server with claude.ai — the highest-value piece.** The whole point:
stop hand-reciting "here's what's in my pantry" every time. Claude reads the
pantry directly in cooking mode. Almost certainly also the *lowest-friction
update path* — say "I used the last of the noodles" and Claude writes it
back — which is the real answer to the pantry-is-tedious problem.
Everything else in this doc (web what-can-i-cook, shopping rethink, web
cook-logging, fixed recipes, §4.1 substitutions) is **deferred** — nice, not
now. Detailed below for when we get there.
**The synergy that justifies the order:** #3 is *why* #1 only needs to be "a
bit nicer" rather than perfect. If Claude can both read and update the pantry
conversationally, the web UI becomes the glanceable / manual fallback, not the
primary data-entry surface. So don't over-invest in pantry UI — invest in
making the data MCP can serve trustworthy and easy to nudge.
---
## 1. Auth — log in once, then forget it
Decision: **a simple login page + a very long-lived session** (≈1 year cookie).
Log in once per device; never get prompted again on that device.
- Standard Django session auth (`login_required`) on all `/app/` views — no
HMAC, no signed requests, no per-request secret. (The HMAC idea was heavier
than needed for one user.)
- Set `SESSION_COOKIE_AGE` long (~1 year) and `SESSION_EXPIRE_AT_BROWSER_CLOSE
= False` so the session survives restarts.
- One user account is enough. `/api/` keeps its existing token auth for the
future MCP client.
- This also lets us drop the `@csrf_exempt` hacks — a logged-in session has a
real CSRF token, so HTMX POSTs can be protected normally.
Security is explicitly *not* the top priority, but this gives a real baseline
(no more wide-open `/app/`) at near-zero friction.
## 2. Pantry — the redesign (this is the heart of it)
Today the pantry is "all just frustrating": adding is slow, quantities drift
out of date the moment you cook, expiry dates are fiddly to set and chase, and
it's a separate chore that's easy to forget. The fix is to **stop tracking the
pantry like a spreadsheet and track it like a fridge you glance into.**
### 2.1 Track presence, not precise amounts
Replace exact decimal quantities as the primary signal with a simple state:
> **In stock · Running low · Out**
- One tap to change state (e.g. cycle In → Low → Out, or three buttons).
- Quantity becomes *optional metadata* (a free note like "½ bag", "3 left"),
never required, never the thing the app reasons about.
- This kills the "quantities drift after cooking" problem outright — you don't
maintain a number, you flip a flag when you notice.
> **Open decision:** some items genuinely want a rough count (eggs, noodle
> nests). Proposal: keep the optional quantity note for those, but the matcher
> and shopping logic only ever look at the In/Low/Out state. Confirm before
> building.
### 2.2 Fast add
- A single search box: type → autocomplete against known ingredients (and
aliases) → tap to add. Defaults its unit/location from the `Ingredient`
record, state defaults to "In stock". Zero further fields needed.
- If the name isn't known, add it inline in the same flow (it auto-creates the
ingredient, like the API already does).
- **Quick-add chips** for staples and recently-used items — one tap, no typing.
- On mobile this is the make-or-break screen: the search box and chips sit near
the bottom (thumb reach), adding an item is one tap, and the In/Low/Out
control on each row is a proper button, not a fiddly inline link. The current
table-of-tiny-actions layout is exactly what's being replaced.
### 2.3 Expiry stops nagging
- Expiry dates become fully optional and de-emphasised. No required date entry.
- Instead of chasing exact dates, a lightweight **"use soon"** flag you can tap
on an item (and an optional date if you actually care for something specific).
- Keep the existing freezer rule (frozen = no expiry). Defrosting (freezer →
fridge) can set a soft "use soon" instead of computing a hard date.
### 2.4 Updating the pantry as a by-product, not a chore
The biggest win against "I forget to update it": make other actions update the
pantry for you, so the standalone chore mostly disappears.
- **Logging a cook** optionally flips the ingredients it used toward Low/Out
(replaces the brittle decimal-deduction logic).
- **Checking off a shopping item** marks that ingredient back to "In stock" in
the pantry — closing the buy → restock loop automatically.
## 3. Recipes & "what can I cook" — keep it, but make it one implementation
Decision: **keep meta-recipes and the what-can-i-cook matcher** — it's the part
Tom actually wants. But:
- There are currently **two separate matchers** (`views.what_can_i_cook` JSON
and `views_htmx.recipes_page` HTML) that have already drifted. Collapse to
**one shared function**; both the page and any API/MCP call use it. (This is
also the "two implementations hanging around" Tom flagged.)
- The matcher moves to **presence-based**: a required slot is satisfied if any
of its options is In (or Low) — no quantity comparison. Simpler, and matches
the new pantry model.
- Servings is currently hardcoded to 2 in the web page; with a presence-based
matcher, servings stops mattering for "can I cook this", so we can just drop
the servings input from that view.
- Fixed `Recipe`s: keep the model, but **de-scope the URL import for now** — it
never links ingredients (see `research.md` §7), so imported recipes are
half-broken. Cooking ideas live in claude.ai cooking mode anyway. Don't
invest here until the MCP phase, if ever.
## 4. Shopping list — rethink the flow
Tom was explicitly unhappy with the current flow. Requirements:
- Generation stays smart (staples that are Out, "use soon" items, gaps for
required recipe slots) but should be **additive and forgiving** — generating
again shouldn't wipe manual additions or duplicate.
- Checking an item off should (a) cross it out and (b) restock it in the pantry
(see §2.4).
- Needs a clear "done shopping" action that tidies the list.
- Keep it dead simple — a phone checkbox list, grouped by aisle/section.
### 4.1 "Can't find it at the shop" — substitutions (important to Tom)
A recurring real pain: Tom goes to the shop to buy something and it's not
there, and then he's stuck. He wants help *in the moment* knowing what else
works.
- **Partial fix available now, no LLM needed:** the meta-recipe slot model
*is* a substitution table — a protein slot already lists pork mince OR
chicken, etc. So a shopping-list item that came from a recipe slot can show
"alternatives that fit the same slot" inline. If you can't find pork mince,
the list shows the other proteins that recipe accepts.
- **Richer substitutions need the LLM/MCP (Phase 2):** general swaps the app
has no data for ("no fresh basil → dried is fine", "no crème fraîche → use
yoghurt") require world knowledge. That's a claude.ai-via-MCP job: it reads
the shopping item + recipe context and suggests a real-world substitute.
- So: ship slot-based alternatives in the web UI now; treat "smart, open-ended
substitution at the shop" as a headline use case for the MCP phase.
## 5. Cook log — make it usable from the web
- Add a **web UI way to log a cook** (currently API/admin only): pick a
meta-recipe, tap the slot choices you used, optional rating + note, save.
- `log-cook` must accept and store `rating` (the model field exists; the
endpoint ignores it today).
- Route cook-logging through model validation so the "exactly one recipe link"
rule is actually enforced (today's `log_cook` bypasses `clean()`).
## 6. Cleanup / housekeeping (do first)
- **Run `/simplify` first**, before feature work, to clear the obvious cruft
(duplicate matcher, dead `filterset_fields`/`search_fields` with no
django-filter installed, the freezer-first deduction bug if deduction
survives the redesign).
- Production baseline: `DEBUG = False`, real `SECRET_KEY` from env. Low
priority but cheap.
## 7. Later — MCP (Phase 2, optional)
Once the web UI is solid, wrap the existing API in an **MCP server** so
claude.ai "cooking mode" can read the pantry and (maybe) log cooks. This is the
integration Tom actually wanted from Caine, without the per-call cost. Explicit
non-goal for now — listed so the API stays MCP-friendly in the meantime.
Two things this phase unlocks (and why it's worth keeping the API clean):
- **Cooking blocks still work.** MCP only *feeds data into* the claude.ai
conversation — it gives Claude tools to read the pantry. It does not change
how claude.ai renders cooking mode. So Claude pulling ingredients from the
food app and Claude producing its nice formatted cooking blocks are
independent: you get the blocks *and* they're grounded in your actual pantry.
- **Smart shop-floor substitutions** (see §4.1) — the open-ended "this isn't on
the shelf, what else works?" question is the main thing MCP buys over the
built-in slot alternatives.
---
## Build order (current)
1. `/simplify` pass (running) — behavior-preserving cleanup of the existing
code so the next steps build on something tidy.
2. **Pantry polish** — In/Low/Out state + fast add in the web UI (§2). Scoped
to "a bit nicer", not the full redesign.
3. **Auth** — login page + ~1-year session, drop `@csrf_exempt` (§1). Fold the
cheap production baseline (DEBUG off, real SECRET_KEY) in here since we're
touching settings anyway.
4. **MCP server** — the goal. Expose pantry **read and update** to claude.ai
cooking mode, so Claude can both see what's in stock and write changes back.
Reuses the existing `/api/` + token auth.
Deferred until after the above (still wanted, just not now): web
what-can-i-cook, §4.1 slot-based substitutions, shopping flow rethink, web
cook-logging.
## Open decisions to confirm before building
- **Quantity**: drop exact decimals entirely in favour of In/Low/Out + an
optional free-text note? (§2.1) — recommended yes.
- **Fixed recipes**: park the URL-import feature rather than fix it? (§3) —
recommended yes.
- Anything here that's actually *more* friction than today — call it out.
---
## Original notes (Tom, preserved)
+ interface: never really ended up using caine due to cost issues. was a nice idea though.
+ if we went down the route of llms again, maybe a mcp. could claude.ai connect with it
+ current food flow is using claude.ai and its "cooking mode", issue is that it doesnt integrate with my pantry
+ pantry was tedious to update, not sure the best approach here. might just have to tough it out
+ webui interface was generally clunky
+ security not the highest prio but would be good to have some basic auth
+ lets run the /simplify skill firstly
+ tagging recipes never really worked, feels like theres two implementations of it hanging around
+ auth just scares me, i want the least friction way of doing this. a hmac secret or something might be nice for fe <> be and a simple log in page would be good. although i really dont want to have to log in every time
+ pantry: it's all just frustrating at the moment (adding slow, quantities drift, expiry fiddly, easy to forget)
+263
View File
@@ -0,0 +1,263 @@
# Research: How the Food app works today
> Snapshot taken 2026-06-22, reading the code on `master` (HEAD `96d9c4b`).
> Purpose: establish ground truth before making changes. Where the shipped
> docs (`Meal Planning System.md`) disagree with the code, the **code** is
> treated as authoritative and the discrepancy is called out.
---
## 1. What this is
A single-user Django app for Tom: pantry inventory + "meta-recipes"
(cooking templates with swappable ingredient slots) + a smart shopping list +
a cook log. Two front doors:
- **REST API** (`/api/`) — token auth, built for Caine (the Matrix assistant).
- **HTMX web app** (`/app/`) — server-rendered HTML fragments, no auth.
Plus Django admin (`/admin/`) as the full-CRUD fallback.
There is **one Django app**, `kitchen`, holding everything. ~1000 lines of
Python total. No Celery, no JS build, no separate frontend — it's deliberately
small.
## 2. Layout
```
food_project/ Django project config
settings.py single settings file, DEBUG=True (see §8)
urls.py routes /admin/, /api/, /api-auth/, /app/
kitchen/
models.py all 11 models
admin.py admin registrations + inlines
serializers.py DRF serializers (read + nested write)
views.py DRF viewsets + the "smart" API endpoints (JSON)
urls.py /api/ router + custom endpoints
views_htmx.py HTML-fragment views for the web app
urls_htmx.py /app/ routes
templates/kitchen/ base.html + 4 pages + 5 partials
static/kitchen/ htmx.min.js (vendored)
management/commands/seed.py initial data loader
migrations/ 0001_initial, 0002_add_rating_and_preferences
deploy/food.service systemd unit (gunicorn, runs as user `openclaw`)
db.sqlite3.backup-… committed snapshot = seed state only (see §9)
cookbooks/recipes_extracted.json 75 scraped Roasting Tin recipes (data only)
```
Note the README/design-doc say the project lives at `/var/lib/food/` in
production. This working copy is `/home/tom/srcs/food`. The DB
(`db.sqlite3`), `venv/`, and `staticfiles/` are gitignored.
## 3. Tech stack (from requirements.txt)
- Django 5.2.12 (LTS), SQLite, Django REST Framework 3.17.1
- HTMX 2.x (vendored JS), templates rendered server-side
- whitenoise (static files), gunicorn (prod)
- recipe_scrapers 15.11 + requests/lxml/extruct for URL recipe import
## 4. Data model (`models.py`)
The system models two kinds of meal: **meta-recipes** (templates) and
**fixed recipes** (traditional ingredient lists). Everything else supports
those plus inventory.
| Model | Role | Key fields / rules |
|---|---|---|
| `Tag` | ingredient category | just a unique name (protein, carb, veg, …) |
| `Ingredient` | master ingredient | `name` (unique, stored lowercased), `default_unit`, `tags` (M2M), `shelf_life_days`, `aliases` (JSON list), `preferences` (free text — how Tom likes it cooked) |
| `PantryItem` | current stock | FK ingredient, `quantity` (Decimal), `unit`, `location` ∈ {fridge,freezer,cupboard}, `stored_date` (auto), `expiry_date`, `is_staple`, `notes` |
| `MetaRecipe` | template | name, method (markdown text), times, `default_servings`, `gear_needed`, `tags` (JSON) |
| `Slot` | swappable category in a template | FK meta_recipe, name, `required`, `max_choices` |
| `SlotOption` | an ingredient that can fill a slot | FK slot+ingredient, `quantity_per_serving`, `unit`, `notes` |
| `MetaRecipeBase` | always-needed ingredients | FK meta_recipe+ingredient, `quantity_per_serving`, `unit` (onion/garlic/oil etc.) |
| `Recipe` | fixed recipe | name, method, times, `servings`, `source_url`, `source_book`, `tags` |
| `RecipeIngredient` | line in a fixed recipe | FK recipe+ingredient, `quantity`, `unit`, `optional` |
| `CookLog` | what was cooked | date (auto), FK to **either** meta_recipe **or** recipe, `slot_choices` (JSON), `servings`, `rating` (1-5), `notes` |
| `ShoppingListItem` | shopping line | optional FK ingredient + fallback `name`, qty/unit, `reason`, `checked` |
### Model-level business rules (enforced in `clean()`/`save()`)
- **`PantryItem.save()` calls `full_clean()`** on every save, so the rules
below fire on API writes, admin writes, and HTMX writes alike.
- Freezer item **must not** have an expiry date → raises ValidationError.
- Fridge item with no expiry auto-fills from `ingredient.shelf_life_days`
(if set); otherwise left null (no error).
- **`CookLog.clean()`**: must link to exactly one of meta_recipe / recipe
(not zero, not both). *But note `CookLog` does not override `save()`, so
`clean()` only runs when something calls `full_clean()` — DRF does, the
`log_cook` endpoint does **not** (it uses `objects.create`). So the
"exactly one" rule is not actually enforced on the main cook-logging path.*
## 5. The API (`views.py`, `/api/`)
Auth: `TokenAuthentication` + `SessionAuthentication`, **all endpoints
`IsAuthenticated`**. Caine uses a token; browser/admin uses session.
**Plain CRUD viewsets** (DefaultRouter): tags, ingredients, pantry,
meta-recipes, slots, slot-options, meta-recipe-bases, recipes,
recipe-ingredients, cook-log, shopping-list. `filterset_fields` are declared
on a couple of viewsets but **django-filter is not installed** (not in
requirements) and not in `INSTALLED_APPS`, so those filters are effectively
inert — query params like `?location=fridge` are silently ignored. Same for
`search_fields` without a `SearchFilter` backend configured.
**Custom endpoints** (the interesting logic):
- `GET /api/what-can-i-cook/?servings=N` — builds an in-memory pantry lookup
(ingredient_id → list of stock rows with expiry flags), then for every
meta-recipe checks each base ingredient and each slot, and for every fixed
recipe checks each line. Classifies as `ready` ✅ / `partial` ⚠️ /
`missing` ❌ and sorts ready-first. Staple base ingredients are assumed
always available. Expiry warnings attached per ingredient.
- `POST /api/log-cook/` — creates a `CookLog`; if `deduct: true`, subtracts
used quantities from the pantry via `_deduct_ingredient`. Accepts
meta_recipe_id **or** recipe_id, `slot_choices`, `servings`, `notes`.
**Does not accept `rating`** even though the model has the field.
- `POST /api/bulk-pantry-add/` — photo-intake path. Resolves each item by
name/alias, **auto-creates the ingredient if unknown**, computes fridge
expiry from shelf life, and merges into an existing same-location stock row
if one exists (adds quantity, refreshes expiry). Marks known staples via a
hardcoded name set.
- `GET /api/generate-shopping-list/?recipes=…&servings=…&add=true`
suggests: (1) staples at qty 0, (2) items expiring within 2 days,
(3) per requested recipe, the first option of any required slot that can't
currently be filled + missing base ingredients. Dedupes by name, sorts by
section+priority, optionally writes `ShoppingListItem` rows.
- `POST /api/create-meta-recipe/` (POST=create, PUT=update) — builds a whole
meta-recipe (slots + options + bases) in one nested call. On PUT it
**deletes and rebuilds** all slots and bases. Auto-creates ingredients by
name, can tag them.
- `POST /api/import-recipe/` — fetches a URL, parses with `recipe_scrapers`,
optionally creates a `Recipe`. **See the gotcha in §7 — it never creates
`RecipeIngredient` rows.**
`_deduct_ingredient` ordering note: the docstring/comment says "prefer fridge,
then cupboard, then freezer", but the queryset only does
`.order_by("expiry_date")`. In SQLite NULLs sort first, and freezer items have
null expiry — so **freezer stock is actually consumed first**, the opposite of
the stated intent.
## 6. The HTMX web app (`views_htmx.py`, `/app/`)
Four pages, server-rendered, each swapping HTML partials over HTMX:
- **Pantry** (`/app/`) — add item (auto-creates ingredient, merges into
existing stock), delete, move fridge↔freezer (freezer clears expiry; fridge
sets +shelf_life or +7d), inline-edit expiry. Items grouped fridge /
freezer / cupboard with expired/expiring colour badges.
- **Recipes** (`/app/recipes/`) — "what can I cook", **meta-recipes only**
(fixed recipes are *not* shown here, unlike the API endpoint). Servings is
**hardcoded to 2**. This re-implements the matching logic from
`what_can_i_cook` in a second place (see §7).
- **Shopping** (`/app/shopping/`) — generate (staples + expiring + recipe
gaps, with a summary count), toggle checked, clear checked.
- **Cook Log** (`/app/log/`) — read-only history of the last 50 entries with
star ratings. **There is no way to create a cook log from the web app**
only via API or admin.
All HTMX action views are `@csrf_exempt` (commit `6786502` — "localhost-only
app"). CSRF token is still wired into `base.html` and sent, but the server
ignores it.
Front-end gaps worth knowing before changing things: !! this has become more important since no more caine
- No UI to log a cook, set a rating, or pick slot choices for a real meal.
- No UI for fixed recipes at all (browse, import, or cook).
- No UI to edit ingredients, aliases, preferences, tags, or staples.
- No UI to manage/create meta-recipes (admin or API only).
- Servings is fixed at 2 across the recipes page.
## 7. Duplication & correctness traps (read before editing)
1. **Two copies of the "what can I cook" matcher.** `views.what_can_i_cook`
(JSON, includes fixed recipes, configurable servings) and
`views_htmx.recipes_page` (HTML, meta-only, servings=2) implement the same
logic separately. They have already drifted. Any change to matching rules
must be made in both, or the logic should be extracted into one shared
helper first.
2. **Recipe URL import creates a `Recipe` with zero `RecipeIngredient`s.**
`import_recipe_url` stores the scraped ingredient list only as raw text in
the response ("needs_review"); it never links ingredients to the model.
Consequence: an imported fixed recipe has an empty ingredient list, so
`what_can_i_cook` considers it **always "ready"** (nothing to be missing).
Linking ingredients is a manual admin/API step that nothing automates yet.
3. **Three different "expiring soon" windows.** `what_can_i_cook` uses ≤2
days, `PantryItem.expiring` API action uses ≤3 days, shopping generation
uses ≤2 days, and the HTMX pantry/recipe views use ≤2 days. No single
constant — easy to make them inconsistent further.
4. **`log_cook` bypasses `CookLog.clean()`** (uses `.create`, no
`full_clean`), so the "exactly one recipe link" invariant isn't enforced
on that path, and `rating` can't be set through it.
5. **Deduction order contradicts its comment** (see §5).
6. **`is_staple` / restock relies on quantity reaching exactly 0.** Staple
restock logic filters `quantity=0`. Items are decremented by deduction and
merged on add, but nothing deletes a depleted row, so a staple at 0 stays
as a 0-qty row — which is what restock/shopping keys off. Fine, but means
stock rows accumulate rather than disappear.
7. **Declared `filterset_fields`/`search_fields` do nothing** (no
django-filter / filter backend installed) — don't rely on them.
## 8. Security / production state
Matches the "TODO" list in the design doc — none of these are done in code:
- `DEBUG = True` and a literal `django-insecure-…` `SECRET_KEY` checked into
`settings.py`. No env-var override.
- `ALLOWED_HOSTS` includes `food.tomflux.xyz` and `food.jihakuz.xyz`.
- **HTMX app has no authentication** and is CSRF-exempt. The design doc says
it's exposed at `food.tomflux.xyz`; protection relies entirely on nginx
blocking `/api/` and `/admin/`, and on nobody pointing at `/app/`. Anyone
who can reach `/app/` can read and mutate the whole pantry.
- API is properly auth'd (token/session, IsAuthenticated).
- Secrets/hardening (DEBUG off, real SECRET_KEY, session-auth on `/app/`) are
all still open.
## 9. Seed data vs. the live database (important)
`seed.py` creates: 8 tags, 29 ingredients, 22 pantry items, and **only 2
meta-recipes** (Stir Fry, Traybake) — no fixed recipes, no cook logs.
The committed `db.sqlite3.backup-20260402-174550` matches the seed exactly:
2 meta-recipes, 0 fixed recipes, 0 cook logs, 0 shopping items, 16 slot
options.
The design doc, however, describes **6 meta-recipes** (the 4 extra from "The
Quick Roasting Tin": Baked Pasta, One-Tin Curry, Roasted Fish, Traybake
Meatballs), 30+ extra ingredients, and 75 imported recipes. **None of that is
in this repo** — it was created on the production DB via the
`create-meta-recipe` / import APIs and never re-seeded. `cookbooks/
recipes_extracted.json` holds the raw scraped data but nothing loads it
automatically.
Implication: a fresh `migrate && seed` here gives you the minimal 2-recipe
system, *not* what's described in the docs or running in prod. If you need
parity, you'd pull the prod DB or replay the API calls.
## 10. Things to decide before changing (open questions)
These come straight out of the gaps above — likely candidates for "the
changes" depending on intent:
- **Auth on `/app/`** — the standing TODO. Add `login_required` (session
auth) or keep relying on nginx?
- **Cook logging from the web** — currently API/admin only; no rating capture
on the main path.
- **Fixed recipes** are half-built: importable but not ingredient-linked, not
shown in the web UI, and trivially "always cookable".
- **Unify the two matchers** before touching matching behaviour.
- **Production hardening** (DEBUG, SECRET_KEY) untouched.
- **Shopping list UX** — the design doc explicitly notes "Tom not happy with
current flow, needs more thought."
---
*Method: read every Python module, all templates, settings, urls, migrations,
the seed command, the deploy unit, and inspected the committed SQLite backup.
No code was changed.*