Commit graph

20 commits

Author SHA1 Message Date
edf679504d v0.3 step 1: foods schema + USDA SR Legacy density seed
Phase A foundation. Cobb 2026-04-29: 'go big or go home' on density-table
aggregator — this commit lands the schema + seed data so the aggregator
engine has something to look up against in step 2.

DB:
- migration 010: cauldron_foods (canonical_name PK, density_g_per_ml,
  default_unit_class enum mass/volume/count/mixed, common_size_g,
  category, usda_fdc_id, source enum)
- migration 011: cauldron_food_mapping (per-household Mealie food_id →
  cauldron canonical food_id, used by aggregator + foods-dedupe later)

Seed data:
- scripts/build_foods_seed.py — extractor that walks USDA SR Legacy
  foodPortions, derives density g/ml from cup/tbsp/tsp/fl-oz/ml/etc
  measurements (handles SR Legacy's quirk of putting unit in 'modifier'
  with measureUnit.name='undetermined'), filters out babyfood / branded
  / fast-food / alcoholic-beverage clutter, normalizes names, categorizes
  via longest-keyword-wins
- cauldron/data/foods_seed_usda.json — 2,462 foods with density values
  derived from USDA. 636KB, ships in the image.
- cauldron/data/README.md — regen instructions + known issues / iteration
  plan (next pass: claude-curated cleanup → ~500-800 high-relevance entries
  + count-based foods like egg/onion that USDA doesn't cover)

Loader (cauldron/foods.py):
- load_seed_if_empty(db) called on app startup right after migrate().
  Idempotent — won't reload if table is non-empty.
- reload_seed(db) for forced reloads (INSERT IGNORE).
- search_food(db, name) helper for the aggregator + UI.

Categories present in seed:
  produce-vegetable: 300, spice: 256, dairy: 207, condiment: 197,
  legume: 189, meat: 166, beverage: 153, baking: 129, produce-fruit: 128,
  oil-fat: 126, nut-seed: 115, grain: 89, other: 407

The 407 'other' bucket and the verbose USDA names ('mayonnaise, reduced
fat, with olive oil') will get cleaned up via clawdforge in step 3.
For now the aggregator can already do the math against this seed; the
unit-conversion engine is the next commit.
2026-04-28 22:03:17 -07:00
c7ee84d70a search: local fuzzy recipe index — way smarter than Mealie's lexical default
Cobb: 'searching recipes is a bit off. lets make that way way more on
point. need to be the google of recipe searching.'

Architecture:
- New cauldron_recipe_index table mirrors enough of Mealie's recipe shape
  to fuzzy-rank locally without round-tripping. Migrations 008+009.
- Refresh on first /recipes load + every 5 minutes + on-demand button.
  Single page-200 pull from Mealie covers Cobb's 226 recipes in one trip.
- recipe_index.py — flatten_recipe(), refresh_household_index(),
  search_index().

Search algorithm (rapidfuzz):
- Multi-field weighted: name×1.00, tags×0.85, cats×0.80, foods×0.70,
  ings×0.55, description×0.45 (max-of wins, not sum, to avoid noise spike)
- Three scorers per field: WRatio (overall), partial_token_set_ratio
  (handles 'spag bol' → 'Spaghetti Bolognese'), token_set_ratio
  (order-independent)
- Substring-of-query in title bonus +20
- Floor 50 to filter junk
- Top-80 returned

API:
- /api/recipes.json now uses local index for both search and browse
- /recipes route same — first-page server-render from index
- POST /api/index/refresh — manual refresh button (admin-y)
- ?q=...  → ranked fuzzy results, paginated
- no q   → ordered browse from index, paginated, has_next via lookahead

Performance:
- Local index query: ~5ms for browse
- Search across 226 rows × 6 fields × 3 scorers: ~60ms
- Should feel instant compared to Mealie's network round-trip
2026-04-28 21:37:12 -07:00
8e53a84121 fix: build mealie permalink as /g/<group>/r/<slug>, not /recipe/<slug>
Cobb caught it. Mealie's web URL format is:
  https://recipes.sulkta.com/g/hayes-house/r/dairy-free-bread-recipe

Was emitting /recipe/<slug> which 404s.

Added _user_group_slug() helper that pulls .group.slug from /api/users/self
(handles dict-or-string-or-fallback shapes for cross-version compat),
threads the proper URL through both:
- /api/recipes/<slug>.json (used by the modal)
- /recipes/<slug> server-rendered detail (used as fallback for direct links)

Falls back to the old /recipe/<slug> path if we can't resolve a group
slug (won't break, will just 404 in Mealie if that ever happens).
2026-04-28 21:33:10 -07:00
3c4c0c027d ux: recipe card click → modal overlay, preserves filters/scroll
Cobb: 'new page resets all filters. bad flow.' Right.

Modal pattern:
- Click any .recipe-card anywhere → fetches /api/recipes/<slug>.json,
  opens a full-screen-on-mobile / centered-on-desktop modal
- The underlying recipes page (search query, sort chip, category chip,
  scroll position) stays put
- Browser back button closes the modal (history.pushState + popstate
  listener)
- /recipes/<slug> still works server-side as fallback for direct links
  and shared URLs (no auto-modal-open on page load)

Implementation:
- New endpoint GET /api/recipes/<slug>.json — same data the detail page
  used, plus a public_url field for the 'in mealie ↗' deep link
- Modal HTML lives in _base.html so it's available everywhere
- Click delegate at document level catches .recipe-card clicks app-wide;
  Mushroom button click bubbles back out (skipped) so pin toggle still
  works without opening the modal
- Modal contains: title bar (sticky), scrollable body (recipe meta pills,
  description, ingredients ticked list, numbered instructions), sticky
  footer with pin button + 'in mealie' link
- Pin button in modal syncs the matching card's picked state on close so
  the grid reflects the change without a refetch

Mobile:
- Modal slides up from bottom, takes full viewport-1 of height
- Close button is 40px circle, top-right
- backdrop-blur 6px, body scroll-locked while open
- Tap outside the modal card → close (e.target === backdrop check)
- Esc key closes
- Cmd/Ctrl/Shift/middle-click still opens in new tab (don't intercept)

Desktop:
- Modal centered, max-width 720px, max-height 86vh
- Backdrop has 30px padding so the card has breathing room
2026-04-28 21:30:09 -07:00
9e62a3e17f fix /plan 500 + bigger touch targets + sort/category chips + sticky search
The /plan crash:
  AttributeError: 'str' object has no attribute 'get'
  in sync_user_household — Mealie's user-self response sometimes returns
  household as a slug-string (newer versions) instead of a dict.
Fix: defensive — accept dict OR str, also fall back to top-level
householdId / householdSlug fields. Doesn't crash if all are missing.

Mobile + UX pass (Cobb 2026-04-29 — 'two kids screaming at the store'):
- Mushroom pick button bumped 30px → 48px (Apple HIG min tap target),
  inner SVG 16 → 24px, kept opacity transition. Stays one-handed-friendly.
- Recipe cards: padding 14px → 18px, min-height 88px, font-size 1.08em
  → 1.2em on the title. Bigger comfortable tap area.
- Recipe grid: 1col by default, 2col at 720px+, 3col at 1100px+ (was
  560/900). Phones get single column, the bigger card.
- Search bar: 14px → 16px font (kills iOS auto-zoom on focus), 8px → 10px
  padding. Now sticky at top of /recipes with backdrop-blur.
- Pill chip rows for sort + category — horizontally scrollable, hidden
  scrollbar, pill rounded shape, active state in purple-deep bg.
- Sort options: newest, recent (last_made), a-z, updated. Default = newest.
- Category chips: pulled live from Mealie's
  /api/organizers/categories, top 14 shown. 'all' chip clears filter.

Mealie client: list_recipes() now accepts orderBy + orderDirection +
categories[] + tags[] params. New list_categories() helper.

Server: _sort_to_order() maps our sort keys to Mealie's orderBy. Recipes
+ json endpoints both honor sort + cat query params and pass through to
the AJAX paginator.
2026-04-28 21:26:22 -07:00
1540c2f436 v0.2: household-shared picks pool + /plan with lock button + scoreboard + streak
DB:
- migration 006 — cauldron_households (mirrors Mealie household), members
- migration 007 — cauldron_meal_plans (per household per week, lock state)
- new helpers: upsert_household, add_household_member, get_user_household_id,
  list_household_member_subs, get_or_create_plan, lock_plan,
  auto_lock_past_unlocked_plans, household_scoreboard, household_streak,
  list_household_pick_slugs, list_household_picks_with_pickers

Backend:
- sync_user_household() — pulls Mealie's /api/users/self, upserts the
  household row, ensures membership. Fires on /connect-mealie POST and
  lazy on every /me / /picks / /plan / /recipes load.
- current_household_id() helper used by all user-facing routes
- /recipes + /api/recipes.json now mark items.picked=True if ANYONE in the
  household has pinned (shared pool, not per-user)
- /picks now renders household-pooled view with attribution (who pinned what)
- /plan replaces stub: shows current week's lock state + lock button +
  scoreboard + streak. POST /api/plan/lock locks this week's plan with
  reason='user'. Past unlocked weeks auto-lock on read with reason='auto'.
- /list still stub (v0.3 territory)

UI:
- picks.html: each pick shows '🍄 pinned by <name(s)>' attribution row,
  unpin button only on your own picks, household_size in lede
- plan.html: NEW. Lock state pill, lock button (open weeks only), scoreboard
  table with rank/wins/last_win, streak ribbon ('🔥 abby on a 3-week run')
  if streak >= 2
- me.html: shows household name + member count

Locking semantics match Cobb's pick:
- (c) both — user-lock = scoreboard win, auto-lock past calendar = no win.
  Auto-lock fires lazily on /plan view (no cron needed for v0.2).
- Picks pool = (a) shared across household.
- Game shape = medium (locked-by + tally + streak counter, room for richer
  badges/notifs in v0.4).
2026-04-28 21:11:11 -07:00
adec91486c v0.2 — meal picks, infinite scroll, search, mushroom vibes, mobile polish
Cobb pass: more mobile friendly + infinite scroll + auto-search +
add-to-AI-plan picks list + mushroom vibes.

DB:
- New migration 005 — cauldron_meal_picks (authentik_sub, recipe_slug,
  recipe_name, added_at). Per-user wishlist for the v0.3 AI meal-plan
  generator. add/remove/list helpers in DB.

Backend:
- GET  /api/recipes.json?page=N&q=Q — paginated + searchable, returns
  {items, page, total, total_pages, next}. Each item is annotated with
  picked: bool from the user's pick set.
- POST /api/picks/<slug>            — add (slug + optional name body)
- DEL  /api/picks/<slug>            — remove
- GET  /api/picks.json              — list current picks
- GET  /picks                       — picks page (replaces empty stub)
- Mealie.list_recipes() now accepts search=...

Frontend:
- recipes.html rebuilt:
  - sticky search bar with 250ms debounce, hits /api/recipes.json?q=
  - IntersectionObserver-driven infinite scroll, loads page+1 when the
    sentinel comes into view (200px rootMargin, AbortController for
    in-flight cancel on new search)
  - per-card mushroom toggle button (top-right) — POST/DELETE to picks
    with optimistic UI flip + rollback on failure
  - picked cards get a left purple-glow stripe + tinted background
- _recipe_card.html partial — first-page server-render shares markup with
  JS-rendered subsequent cards (mushroom SVG inline, same shape)
- recipe_detail.html — '🍄 pin for ai plan' button toggles state in place
- picks.html — list of current picks with remove button + v0.3 explainer
- Topbar nav: dropped /home, added /picks

Mushroom vibes:
- Hand-rolled SVG toadstool (purple cap, bone stem, dark spots) used as
  the pick toggle icon — it's the gesture itself
- Same mushroom tiled into the body bg pattern at ~5% opacity in the
  bottom-right of the 160px sigil tile, alongside the existing pentagram
- Mushroom emoji on the detail page button + picks page nudge

Mobile pass:
- Topbar nav scrolls horizontally on narrow screens, brand-sub hidden
  under 720px, larger tap targets on cards, font-size pulled in slightly
- Recipe grid: 1 col <560, 2 col 560-900, 3 col 900+
- Page-head + button + card padding all tightened on small screens
2026-04-28 20:53:07 -07:00
6c3a45f57a ui: back off the heavy gothic — revert _base.html to dd9cc26 (the polished sulkta-blend pass) 2026-04-28 20:44:57 -07:00
77ba241b5a ui: push gothic — drop caps, filigree corners, blood-wine accent, flourish hr
Cobb: 'more gothic somehow.' Pushed in concrete elements:

- Drop cap on h1 page titles (first letter 1.5em in witch-purple with deep
  glow; lowercase rest) — illuminated-manuscript feel
- h1 also lowercase + serif italic for the .accent span (blood-wine color)
- Drop cap on recipe-card .rname (first letter in blood-wine, jumps to
  glow on hover)
- Panels now have gilt 18px corner brackets (top-left + top-right) via
  ::before/::after — very thin gothic frame, brightens on hover
- Inset 1px highlight on top edge for stone-and-glaze depth
- Three panel variants: .green (gilt brackets stay gold), .purple, .blood
- Panel h2 prefixed with ✦ in blood color (decorative bullet)
- New CSS vars: --blood (#7a2c4a aged bordeaux), --blood-bright (#a83d62),
  --gilt (#8a6e3a aged brass), --gilt-bright (#c0a058)
- Brand mark Cauldron now flanked by ✦ in blood color
- Crumb breadcrumbs prefixed with ❧ (heart-petal) in gilt
- Lede italic with leading curly quote in gilt
- HR is now a custom SVG flourish divider (gilt curlicue with blood center
  dot) instead of a plain line
- Top-of-page-head 60px gilt fade rule
- Body bg replaced pentagram tile with a more atmospheric cross-and-vine
  glyph plus a subtle full-page corner vignette darkening (NOT cWHO scanlines,
  just radial darken)
- Brand letter-spacing bumped to .22em for that ceremonial feel
2026-04-28 20:43:43 -07:00
dd9cc266fa ui: extract templates + add /recipes browse + sulkta-meets-gothic palette
Cobb feedback: pull back from cWHO terminal-coded look, blend Abby's gothic
witch with sulkta.com polish. 'burn tokens till we nail it.'

Style direction:
- Polished dark base like sulkta.com — soft purple/green radial glows on
  near-black, faint witchy pentagram-circle SVG bg pattern at 5% opacity
- NO scanlines, NO CRT vignette overlay (cWHO is too terminal for this)
- Inter for body (sulkta.com), Cinzel SERIF for h1/h2/brand/recipe-card
  titles (gothic flourish), JetBrains Mono for code/labels/uppercase chips
- Soft glow shadows (rgba box-shadow) instead of hard cWHO 3px offsets
- Rounded corners 4-6px throughout
- Smooth fade-in animations on .panel and .page-head
- Pills with subtle background tint (sulkta pill style)
- KV labels in mono uppercase purple — kept the gothic occult-tag feel
- Recipe cards lift on hover with purple glow shadow

Templates extracted from server.py to cauldron/templates/:
- _base.html — full layout shell, topbar, scanlines REMOVED, animation
- me.html — uses {extends '_base.html'}
- connect.html — same
- recipes.html — NEW, paginated grid view
- recipe_detail.html — NEW, full recipe with ingredients + instructions
- stub.html — NEW, placeholder for /plan and /list (v0.3)

Routes added:
- GET /recipes        — user-tier: list via current_user_mealie()
- GET /recipes/<slug> — user-tier: detail view
- GET /plan, /list    — stubs so nav doesn't 404

Server:
- render_template_string → render_template (proper Jinja file lookup)
- Stripped inline _PALETTE_CSS / ME_TEMPLATE / CONNECT_TEMPLATE constants
- Added current_user_mealie() helper to all user-facing routes
2026-04-28 20:38:54 -07:00
a329784063 fix: split MEALIE_API_URL (internal) from MEALIE_PUBLIC_URL (UI link)
Cauldron's container can't resolve 'recipes.sulkta.com' from inside the
sulkta+sulkta-db-net bridges (Lucy's split-horizon doesn't propagate to
container DNS). Symptom: 500 on /connect-mealie POST when validating the
pasted token.

Fix: take the LAN-internal HTTP path direct to mealie. Mealie shares
OpenVPN-rack2's netns, listening on 9000 inside that netns. Both cauldron
and OpenVPN-rack2 are on sulkta-db-net (172.30.1.0/24), so cauldron talks
to 'http://OpenVPN-rack2:9000' via Docker's internal DNS — bypasses
Apache/HTTPS termination on Rackham entirely.

The public URL stays in the UI (so the connect-mealie page deep-link to
mint a token still goes to https://recipes.sulkta.com via the user's
browser, which DOES resolve it).

Also tightened Mealie._get/_put/_post to wrap requests.RequestException
into MealieError so connection failures don't 500 callers.
2026-04-28 20:26:25 -07:00
6588f148e6 ui: halloween underground — black/poison-green/witch-purple, hard edges
Cobb redirect: break away from forest-only, go darker. Halloween / witch /
underground / occult feels.

Palette flipped:
  Black     #050505 deep, #0a0a0e crypt, #110a1a vault
  Purple    #1a0d24 eggplant, #3d1f5a witch, #5a2d8c violet, #b878ff hex
  Green     #1a2611 moss, #2a3a1d swamp, #5a8c3a poison, #88c060 toxic, #9bff5a acid
  Bone      #d8c8a8 main text, #c9b27c warn

Fonts replaced:
  Display   Cinzel (sharp Roman caps, occult/witch trial vibe)
  Body      JetBrains Mono (terminal underground)
  Brand     Nosifer (the dripping-blood Halloween display face — used only
            on the .brand tagline as a single bright accent)

Hard edges everywhere: no border-radius (square corners), no smooth
gradients on elements (only on the body bg as deep purple/green vignette
into black), 3px solid drop-shadows on button hover (the brutalist offset),
sharp 1px borders.

Specifically:
- h1 in toxic green (Cinzel 900, uppercase, letter-spaced) with a 0/24px
  acid-green outer glow
- h2 in hex purple, Cinzel 700, uppercase, with a 1px witch-purple
  underline
- Panel left-border alternates 3px violet → poison-green stripes
- KV labels in hex purple uppercase, occult-tag style
- Buttons rectangular Cinzel uppercase, 3px hard offset shadow on hover
- Brand mark in Nosifer (dripping)
- Code blocks on eggplant bg with hex purple text
- Selection color: violet on bone

Body bg = pitch black (#050505) with two radial vignettes — witch-purple
top-left, swamp-green bottom-right — bleeding into the black like
candlelight under a coven door.
2026-04-28 20:20:42 -07:00
ede799f94e ui: weave purples back into the palette
Cobb course-corrected — wants more purple. Palette now:

  Greens   forest #1f2d1f, panel #2d3a2a, swamp #3a4a35, meadow #6b8e5a/#88a87a
  Purples  deep   #2a1f3a, amethyst #4a2d5e, heather #6b4a8a, light #9b78c4
  Cream    #f0e6cc text, #ddd4ba lede, #c9b27c warn

Where purples land:
- h1 in heather (was meadow); h2 stays meadow for hierarchy
- Background a subtle linear-gradient toward #251f30 in the lower right
- Panel left-border accent in heather
- Links default to light heather; underline in deep amethyst
- kv dt labels in heather small-caps for the magic-grimoire feel
- Code blocks on deep purple bg
- Form input focus ring in light heather
- New .btn-purple variant for primary purple actions
- HR uses a gradient image-border heather→swamp→fade
- Brand tagline in heather italic small-caps

Greens still own structure (panels, body bg base, secondary headers,
buttons primary action). Purples are the magic.
2026-04-28 20:15:38 -07:00
b18ab1103d ui: /me is a real page now, not raw JSON
- New ME_TEMPLATE — palette-locked, shows user identity + Mealie connection
  status + connect/disconnect actions + sign out
- /me.json kept for programmatic callers
- Extracted _PALETTE_CSS shared between /me and /connect-mealie templates
  (forest #1f2d1f bg, panels #2d3a2a, meadow accents #6b8e5a/#88a87a,
  parchment text #f0e6cc/#ddd4ba, Cormorant Garamond serif headers)
- /me also fetches the Mealie /api/users/self for the connected user so
  the page can show 'logged in as <username>, admin: yes/no'
- Connect page polished with cancel button + autocomplete=off on the token
  input

Strict palette: no purple, no neon. As locked.
2026-04-28 20:08:01 -07:00
d3369bb141 db: INSERT IGNORE on schema_migrations to tolerate multi-worker boot race 2026-04-28 19:49:40 -07:00
d333af014e compose: also join sulkta-db-net so cauldron can reach sulkta-mariadb 2026-04-28 19:48:59 -07:00
213801ca70 v0.2 foundation — Authentik OIDC + sulkta-mariadb DB + Fernet crypto
Adds the multi-user plumbing layer underneath v0.1's batch-only API:

- DB module (db.py) — PyMySQL against sulkta-mariadb, in-process migrations.
  Tables: cauldron_users, cauldron_user_mealie_tokens, cauldron_chat_log,
  schema_migrations.
- Crypto module (crypto.py) — thin Fernet wrapper. Master key in env,
  per-row encryption of stored Mealie tokens, decrypt only in-process.
- OIDC module (oidc.py) — Authlib-based Authentik integration. Issuer
  https://auth.sulkta.com/application/o/cauldron/, sub_mode=user_email,
  scopes openid+email+profile. App gated to 'Sulkta Family' group.
- Two-tier Mealie shape — system_mealie (env token, admin batch) +
  current_user_mealie() helper that loads + decrypts the calling user's
  token from DB. Per the v0.2 design (memory/spec-cauldron-v0.2.md).
- Connect flow — /connect-mealie pages walk users through minting their
  own Mealie API token and pasting it back. Validated against
  /api/users/self before encryption + storage.
- Routes — /, /login, /auth/callback, /logout, /me, /connect-mealie,
  /disconnect-mealie. v0.1 admin endpoints kept under bearer auth.
- Mealie.who_am_i() helper added.
- Auth flow uses Authentik subject (sub) as the canonical user key.

UI is minimal — connect-mealie page uses the locked palette
(forest #1f2d1f, panels #2d3a2a, meadow #6b8e5a/#88a87a, parchment text
#f0e6cc/#ddd4ba) and Cormorant Garamond serif headers. Strict palette.
The fuller dashboard / plan / list / recipes views land in subsequent
commits.

Authentik provider PK 24, client_id ZIwEugWWWZinR1KcVC9IT9hpGoTds9ps8XDDHPPN.
Group 'Sulkta Family' (pk 6d0c75e9-...) created with cobb member.

Foundation only — Abby's branded UI and the meal-plan / shopping-list
features land in subsequent v0.2 commits.
2026-04-28 19:47:47 -07:00
63cb347222 compose: pin project name to 'cauldron' so it doesn't bleed into clawdforge namespace 2026-04-28 17:10:38 -07:00
130f96a34f v0.1 — backend bones + ingredient sterilizer
LAN-only Flask API that consumes Mealie (source of truth for recipes / plans
/ lists) and clawdforge (centralized claude -p runner) to do AI work.

v0.1 surface:
  GET  /healthz                          liveness + clawdforge upstream
  GET  /api/recipes                      proxy Mealie recipe list
  POST /api/sterilize/preview/<slug>     dry-run AI parse, return proposals
  POST /api/sterilize/apply/<slug>       write parses back to Mealie

Why sterilizer first: Mealie's CRF parser is mediocre and Cobb's hand-typed
recipes have lots of free-form ingredient strings ("about 2 cups cooked
white rice", "a pinch of salt") that don't aggregate cleanly into a
shopping list. We batch all ingredients of one recipe into a single Sonnet
call via clawdforge, get back parallel structured parses, then on apply
link each to Mealie food/unit records (creating missing by name) and PUT
the recipe back. Preview is non-destructive.

No UI in v0.1 — bearer-auth API only. Frontend + Authentik OIDC + Abby's
swamp/meadow/forest palette arrives in v0.2.

Auth: simple shared bearer in env (ADMIN_BEARER) until OIDC lands. LAN-only
deploy means the bearer is the only gate; no public exposure.

Stack: python:3.12-slim + Flask 3 + gunicorn + requests. No DB in v0.1.
2026-04-28 16:59:11 -07:00
e3277aa2c2 Initial commit 2026-04-28 16:35:30 -07:00