plan: multi-meal slots (breakfast/lunch/dinner) + rename agent → Sage

Big plan-generator addition: each plan can now span multiple meal types,
not just dinner. Default stays dinner-only for back-compat; opt-in via
checkboxes on /plan.

Schema (migrations 028-031):
- cauldron_meal_plan_slots gains meal_type ENUM('breakfast','lunch',
  'dinner','snack','dessert','side') NOT NULL DEFAULT 'dinner'.
- Old UNIQUE key (plan_id, day) → (plan_id, day, meal_type) so a
  Monday can have breakfast AND lunch AND dinner slots.
- cauldron_meal_plans gains meal_types_json (which meals to plan
  for that week — list of strings, defaults to ["dinner"]).

Forge:
- generate_plan accepts meal_types list. Output schema gains meal_type
  per slot. Validates expected_total = slots * len(meal_types) and
  rejects duplicate (day, meal_type) pairs.
- _build_plan_prompt renders MEAL TYPES TO PLAN block, instructing
  Sonnet to match recipe meta.meal_type to slot type (breakfast slot
  → recipe whose meta tags it as breakfast). Falls back gracefully
  when the pool is thin for a particular meal type.

Server:
- /api/plan/generate + regenerate accept body.meal_types, persist via
  db.set_plan_meal_types.
- plan_view decodes meal_types_json into plan["meal_types_list"] and
  builds plan["meal_types_label"] for the readout.

UI (/plan):
- New checkbox row at the top of the pref-block: 🍳 breakfast / 🥪 lunch
  / 🍽️ dinner. Defaults to whatever's persisted (or just dinner).
- Day cards now group multiple meal_type slots per day with small
  meal-type tags above each recipe row. Single-meal plans render the
  same way they always did (no tag shown when only one meal_type).
- readMealTypes() in JS reads checkboxes and ships in the body.

DB:
- save_plan_slots accepts meal_type per slot, defaults to 'dinner'.
- list_plan_slots orders by day then meal_type via MEAL_ORDER.

==

UX rename: "claude" / "sonnet" → "Sage" across all user-visible copy.
Sage doubles as kitchen-herb (theme fit) and wise advisor (planner
role). The internal field name `sonnet_decision` on consolidate +
dedupe proposals is unchanged (it's a data field, not user-facing).
Renames touched plan, consolidate, dedupe_recipes, list, me,
enrich_recipes, sterilize templates. Cobb can swap to Mim or his own
name later — easy global s/sage/whatever/g.

==

The /list 'clear' button removed earlier today (b4cb48b) — not
re-introduced.
This commit is contained in:
Kayos 2026-04-30 21:44:56 -07:00
parent b4cb48bef8
commit a6a28ef6e4
9 changed files with 260 additions and 57 deletions

View file

@ -470,6 +470,33 @@ MIGRATIONS = [
ALTER TABLE cauldron_meal_plans
ADD COLUMN IF NOT EXISTS exclusions_json JSON
""",
# 028 — Multi-meal planning. Slots gain meal_type so a single plan
# row can hold breakfast + lunch + dinner across the same 7 days.
# Default 'dinner' so existing rows keep their semantics.
"""
ALTER TABLE cauldron_meal_plan_slots
ADD COLUMN IF NOT EXISTS meal_type
ENUM('breakfast','lunch','dinner','snack','dessert','side')
NOT NULL DEFAULT 'dinner'
""",
# 029 — Old unique key was (plan_id, day) — now needs to include
# meal_type so a Monday can have breakfast AND lunch AND dinner.
# Drop-and-add, idempotent: catch the "doesn't exist" if already done.
"""
ALTER TABLE cauldron_meal_plan_slots
DROP INDEX uk_plan_day
""",
"""
ALTER TABLE cauldron_meal_plan_slots
ADD UNIQUE KEY uk_plan_day_meal (plan_id, day, meal_type)
""",
# 030 — Per-plan selection of which meal types to generate. List of
# strings: ["breakfast","lunch","dinner"]. Default dinner-only so
# generation is back-compat for users who haven't opted in.
"""
ALTER TABLE cauldron_meal_plans
ADD COLUMN IF NOT EXISTS meal_types_json JSON
""",
]
@ -677,6 +704,27 @@ class DB:
(clean, plan_id),
)
def set_plan_meal_types(self, plan_id: int, meal_types: list | None) -> None:
"""Persist which meal types this plan should generate. None / empty
list defaults to ['dinner'] at generation time."""
import json as _json
clean: list | None = None
if isinstance(meal_types, list) and meal_types:
allowed = set(self.MEAL_ORDER)
ce = []
for m in meal_types:
if isinstance(m, str) and m.strip().lower() in allowed:
ce.append(m.strip().lower())
# Preserve order: breakfast, lunch, dinner, snack, dessert, side
order = {m: i for i, m in enumerate(self.MEAL_ORDER)}
ce = sorted(set(ce), key=lambda m: order[m])
clean = ce or None
with self.conn() as c, c.cursor() as cur:
cur.execute(
"UPDATE cauldron_meal_plans SET meal_types_json=%s WHERE id=%s",
(_json.dumps(clean) if clean else None, plan_id),
)
def set_plan_targets_and_exclusions(
self,
plan_id: int,
@ -779,14 +827,16 @@ class DB:
# in calendar order.
PLAN_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
MEAL_ORDER = ("breakfast", "lunch", "dinner", "snack", "dessert", "side")
def list_plan_slots(self, plan_id: int) -> list[dict]:
"""All slots for a plan, ordered Mon..Sun. picker_subs is decoded
from JSON to a list (or [] if null)."""
"""All slots for a plan, ordered Mon..Sun then breakfast→dinner.
picker_subs is decoded from JSON to a list (or [] if null)."""
import json as _json
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
SELECT id, plan_id, day, recipe_slug, recipe_name, source,
SELECT id, plan_id, day, meal_type, recipe_slug, recipe_name, source,
picker_subs, reason, notes, created_at
FROM cauldron_meal_plan_slots
WHERE plan_id = %s
@ -809,30 +859,39 @@ class DB:
r["notes"] = _json.loads(n)
except Exception:
r["notes"] = None
order = {d: i for i, d in enumerate(self.PLAN_DAYS)}
rows.sort(key=lambda r: order.get((r.get("day") or "").lower(), 99))
day_order = {d: i for i, d in enumerate(self.PLAN_DAYS)}
meal_order = {m: i for i, m in enumerate(self.MEAL_ORDER)}
rows.sort(key=lambda r: (
day_order.get((r.get("day") or "").lower(), 99),
meal_order.get((r.get("meal_type") or "dinner").lower(), 99),
))
return rows
def save_plan_slots(self, plan_id: int, slots: list[dict]) -> int:
"""INSERT IGNORE every slot. Returns count actually inserted —
callers can use this to detect race contention (zero rows = someone
else already saved this plan)."""
else already saved this plan). Each slot must carry a meal_type
(defaults to 'dinner' for back-compat)."""
import json as _json
if not slots:
return 0
inserted = 0
with self.conn() as c, c.cursor() as cur:
for s in slots:
meal = (s.get("meal_type") or "dinner").lower()
if meal not in self.MEAL_ORDER:
meal = "dinner"
cur.execute(
"""
INSERT IGNORE INTO cauldron_meal_plan_slots
(plan_id, day, recipe_slug, recipe_name, source,
picker_subs, reason, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
(plan_id, day, meal_type, recipe_slug, recipe_name,
source, picker_subs, reason, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
plan_id,
(s.get("day") or "").lower()[:10],
meal,
s["recipe_slug"][:255],
(s.get("recipe_name") or s["recipe_slug"])[:500],
s.get("source") or ("pick" if s.get("picker_subs") else "mealie"),

View file

@ -80,6 +80,7 @@ class Forge:
picker_profiles: dict | None = None,
daily_targets: dict | None = None,
exclusions: list | None = None,
meal_types: list | None = None,
model: str | None = None,
) -> list[dict]:
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
@ -111,17 +112,37 @@ class Forge:
if slug:
valid_by_slug.setdefault(slug, p.get("name") or slug)
# Default to dinner-only if not specified — back-compat with the
# original single-meal-per-day behavior.
meal_types_clean: list[str] = []
allowed_meals = {"breakfast", "lunch", "dinner", "snack", "dessert", "side"}
if isinstance(meal_types, list):
for m in meal_types:
if isinstance(m, str) and m.strip().lower() in allowed_meals:
if m.strip().lower() not in meal_types_clean:
meal_types_clean.append(m.strip().lower())
if not meal_types_clean:
meal_types_clean = ["dinner"]
# Stable order: breakfast → lunch → dinner → snack → dessert → side
meal_order = ["breakfast", "lunch", "dinner", "snack", "dessert", "side"]
meal_types_clean.sort(key=lambda m: meal_order.index(m))
expected_total = slots * len(meal_types_clean)
prompt = self._build_plan_prompt(
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
preference=preference, picker_profiles=picker_profiles,
daily_targets=daily_targets, exclusions=exclusions,
meal_types=meal_types_clean,
)
result = self.run(prompt, model=model or "sonnet")
parsed = _extract_plan_slots(result)
if not isinstance(parsed, list):
raise ForgeError("model output: 'slots' must be a list")
if len(parsed) != slots:
raise ForgeError(f"model output: got {len(parsed)} slots, expected {slots}")
if len(parsed) != expected_total:
raise ForgeError(
f"model output: got {len(parsed)} slots, expected {expected_total} "
f"({slots} days × {len(meal_types_clean)} meals)"
)
# Pick attribution lookup keyed by slug → list[sub]
pick_subs_by_slug: dict[str, list[str]] = {}
@ -131,17 +152,21 @@ class Forge:
pick_subs_by_slug[slug] = list(p.get("picker_subs") or [])
out = []
seen_days: set[str] = set()
seen: set[tuple[str, str]] = set() # (day, meal_type) uniqueness
for raw in parsed:
if not isinstance(raw, dict):
raise ForgeError("model output: each slot must be an object")
day = (raw.get("day") or "").strip().lower()
meal = (raw.get("meal_type") or "dinner").strip().lower()
slug = (raw.get("recipe_slug") or "").strip()
if day not in _DAYS:
raise ForgeError(f"model output: bad day '{day}'")
if day in seen_days:
raise ForgeError(f"model output: duplicate day '{day}'")
seen_days.add(day)
if meal not in meal_types_clean:
raise ForgeError(f"model output: unexpected meal_type '{meal}' (expected one of {meal_types_clean})")
key = (day, meal)
if key in seen:
raise ForgeError(f"model output: duplicate slot {day}/{meal}")
seen.add(key)
if not slug or slug not in valid_by_slug:
raise ForgeError(f"model output: unknown recipe_slug '{slug}'")
@ -158,6 +183,7 @@ class Forge:
out.append({
"day": day,
"meal_type": meal,
"recipe_slug": slug,
"recipe_name": valid_by_slug[slug],
"picker_subs": picker_subs,
@ -169,7 +195,7 @@ class Forge:
@staticmethod
def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None,
picker_profiles=None, daily_targets=None,
exclusions=None) -> str:
exclusions=None, meal_types=None) -> str:
pool_lines = []
for r in recipes:
slug = r.get("slug") or ""
@ -364,29 +390,61 @@ class Forge:
"fill the remaining slots.\n"
)
# Multi-meal block: when meal_types is more than just dinner, the
# output is N×7 slots with explicit meal_type. Each pool entry has
# a meta.meal_type tag — Sonnet must match those (a "breakfast"
# slot gets a recipe whose meta.meal_type is "breakfast" or
# something close like "lunch" if no breakfast match).
meal_types_clean = list(meal_types) if isinstance(meal_types, list) and meal_types else ["dinner"]
is_multi_meal = len(meal_types_clean) > 1
meal_block = ""
if is_multi_meal:
meal_block = (
f"\nMEAL TYPES TO PLAN: {', '.join(meal_types_clean)}\n"
"Each day must have exactly one slot per meal type listed above.\n"
"When picking a recipe for a meal slot, prefer recipes whose "
"meta.meal_type matches the slot. e.g. for a 'breakfast' slot, "
"look at the pool entries with `meta meal_type=breakfast` or those "
"tagged 'breakfast'/'brunch'/'morning' in their meta tags. Don't "
"use a 'dessert' meal_type recipe for a dinner slot. If the pool "
"is thin for a meal type, fall back to general pool entries that "
"could plausibly fit (e.g. a hearty breakfast salad for lunch).\n"
)
output_shape = (
'{"slots": [{"day": "monday", "meal_type": "breakfast", '
'"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}'
)
else:
output_shape = (
'{"slots": [{"day": "monday", "meal_type": "dinner", '
'"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}'
)
expected_total = slots * len(meal_types_clean)
meal_types_label = "/".join(meal_types_clean) if is_multi_meal else "dinner"
return (
f"You are a family meal planner. Build a {slots}-day dinner plan "
f"You are a family meal planner. Build a {slots}-day plan ({meal_types_label}) "
f"for the week of {week_start}.\n\n"
f"POOL (all available recipes):\n{pool_block}\n\n"
f"PICKS (recipes the family pre-selected — every pick MUST appear "
f"if pool size >= {slots}; no repeats unless pool < {slots}):\n"
f"PICKS (recipes the family pre-selected — every pick MUST appear in "
f"some appropriate slot if pool size allows; no repeats):\n"
f"{picks_block}\n"
f"{profile_block}"
f"{meal_block}"
f"{pref_block}"
f"{targets_block}"
f"{excl_block}"
"Output JSON ONLY, no prose: "
'{"slots": [{"day": "monday", "recipe_slug": "...", '
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
f"Output JSON ONLY, no prose: {output_shape}\n\n"
"Rules:\n"
f"- Use exactly {slots} recipes\n"
f"- Use exactly {expected_total} slots ({slots} days × {len(meal_types_clean)} meals)\n"
"- Distribute picks evenly across the week — don't bunch them\n"
"- ROTATION: prefer recipes with `last:NNw-ago` further in the past "
"or no history shown. If a recipe has been served 2+ times in 30d "
"(`2×/30d` or higher), DEMOTE it strongly unless it's a household "
"pick. Never repeat the same recipe slug within this 7-day plan.\n"
"- VARIETY: don't fill 5 of 7 slots with the same primary_protein or "
"the same cuisine. Mix it up across the week.\n"
"pick. Never repeat the same recipe slug within this plan.\n"
"- VARIETY: don't fill 5 of 7 dinner slots with the same primary_protein "
"or the same cuisine. Mix it up across the week.\n"
"- meal_type MUST be one of the listed meal types and match the slot.\n"
"- \"reason\" is a one-line user-facing rationale "
"(e.g., \"balances heavy and light meals\", \"honors abby's pick\", "
"\"high-protein lean — pairs with the gym week\", "

View file

@ -617,7 +617,7 @@ def create_app() -> Flask:
pick_count = len(db.list_household_pick_slugs(hid))
# Decode JSON columns for the template + build readout labels
for k in ("daily_targets_json", "exclusions_json"):
for k in ("daily_targets_json", "exclusions_json", "meal_types_json"):
v = plan.get(k)
if isinstance(v, str):
try:
@ -626,6 +626,9 @@ def create_app() -> Flask:
plan[k] = None
targets = plan.get("daily_targets_json") if isinstance(plan.get("daily_targets_json"), dict) else None
exclusions = plan.get("exclusions_json") if isinstance(plan.get("exclusions_json"), list) else None
plan_meal_types = plan.get("meal_types_json") if isinstance(plan.get("meal_types_json"), list) else None
plan["meal_types_list"] = plan_meal_types or ["dinner"]
plan["meal_types_label"] = " + ".join(plan["meal_types_list"]) if len(plan["meal_types_list"]) > 1 else ""
plan["targets_label"] = ""
if targets:
bits = []
@ -761,12 +764,16 @@ def create_app() -> Flask:
plan["preference_prompt"] = preference[:1000]
targets_body = body.get("targets") if isinstance(body.get("targets"), dict) else None
exclusions_body = body.get("exclusions") if isinstance(body.get("exclusions"), list) else None
meal_types_body = body.get("meal_types") if isinstance(body.get("meal_types"), list) else None
if targets_body is not None or exclusions_body is not None:
db.set_plan_targets_and_exclusions(
plan["id"],
targets=targets_body,
exclusions=exclusions_body,
)
if meal_types_body is not None:
db.set_plan_meal_types(plan["id"], meal_types_body)
if targets_body is not None or exclusions_body is not None or meal_types_body is not None:
# Re-fetch so the local plan dict has the persisted values
plan = db.get_or_create_plan(hid, this_monday)
@ -834,7 +841,8 @@ def create_app() -> Flask:
# the household's collective average.
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
# Numeric daily targets + allergen exclusions parsed from the persisted plan row
# Numeric daily targets + allergen exclusions + meal_types parsed
# from the persisted plan row
plan_targets = plan.get("daily_targets_json")
if isinstance(plan_targets, str):
try:
@ -847,6 +855,12 @@ def create_app() -> Flask:
plan_exclusions = _json_loads(plan_exclusions)
except Exception:
plan_exclusions = None
plan_meal_types = plan.get("meal_types_json")
if isinstance(plan_meal_types, str):
try:
plan_meal_types = _json_loads(plan_meal_types)
except Exception:
plan_meal_types = None
try:
slots = forge.generate_plan(
@ -856,6 +870,7 @@ def create_app() -> Flask:
picker_profiles=picker_profiles,
daily_targets=plan_targets if isinstance(plan_targets, dict) else None,
exclusions=plan_exclusions if isinstance(plan_exclusions, list) else None,
meal_types=plan_meal_types if isinstance(plan_meal_types, list) else None,
)
except ForgeError as e:
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
@ -909,12 +924,16 @@ def create_app() -> Flask:
plan["preference_prompt"] = preference[:1000]
targets_body = body.get("targets") if isinstance(body.get("targets"), dict) else None
exclusions_body = body.get("exclusions") if isinstance(body.get("exclusions"), list) else None
meal_types_body = body.get("meal_types") if isinstance(body.get("meal_types"), list) else None
if targets_body is not None or exclusions_body is not None:
db.set_plan_targets_and_exclusions(
plan["id"],
targets=targets_body,
exclusions=exclusions_body,
)
if meal_types_body is not None:
db.set_plan_meal_types(plan["id"], meal_types_body)
if targets_body is not None or exclusions_body is not None or meal_types_body is not None:
plan = db.get_or_create_plan(hid, this_monday)
# Now fall through to the same logic as generate
@ -942,7 +961,7 @@ def create_app() -> Flask:
# the household's collective average.
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
# Persisted numeric targets + allergen exclusions for this re-roll
# Persisted numeric targets + allergen exclusions + meal_types for re-roll
plan_targets = plan.get("daily_targets_json")
if isinstance(plan_targets, str):
try:
@ -955,6 +974,12 @@ def create_app() -> Flask:
plan_exclusions = _json_loads(plan_exclusions)
except Exception:
plan_exclusions = None
plan_meal_types = plan.get("meal_types_json")
if isinstance(plan_meal_types, str):
try:
plan_meal_types = _json_loads(plan_meal_types)
except Exception:
plan_meal_types = None
try:
slots = forge.generate_plan(
@ -964,6 +989,7 @@ def create_app() -> Flask:
picker_profiles=picker_profiles,
daily_targets=plan_targets if isinstance(plan_targets, dict) else None,
exclusions=plan_exclusions if isinstance(plan_exclusions, list) else None,
meal_types=plan_meal_types if isinstance(plan_meal_types, list) else None,
)
except ForgeError as e:
return jsonify({"error": "forge_failed", "detail": str(e)}), 502

View file

@ -67,7 +67,7 @@
<h1>foods <span class="accent">consolidate</span></h1>
<div class="lede">
scan your mealie household foods, cluster lookalikes by similarity,
let sonnet pick the survivor + the rest become aliases on it. mealie
let sage pick the survivor + the rest become aliases on it. mealie
rewrites every recipe ingredient to point at the canonical row.
</div>
</div>
@ -83,7 +83,7 @@
<p>no run yet. kick one off?</p>
<button class="btn btn-purple" id="start-btn" type="button" onclick="startRun()">🪄 scan + cluster mealie foods</button>
<p class="muted" style="margin-top:8px;">
walks your household catalog, clusters by name similarity, asks sonnet for the survivor of each cluster.
walks your household catalog, clusters by name similarity, asks sage for the survivor of each cluster.
first run might take 5-15 min depending on cluster count.
</p>
</div>

View file

@ -60,7 +60,7 @@
<div class="crumb">// dedupe · find duplicate recipes</div>
<h1>recipe <span class="accent">dedupe</span></h1>
<div class="lede">
scan your household recipes for duplicates by name similarity. sonnet
scan your household recipes for duplicates by name similarity. sage
looks at ingredients + step counts + source URLs to decide whether
similar-named recipes are actually the same dish. you confirm per
cluster — DELETE in Mealie is permanent.
@ -77,7 +77,7 @@
<div id="empty-pane" style="display:none;">
<p>no run yet. scan now?</p>
<button class="btn btn-purple" id="start-btn" type="button" onclick="startRun()">🪄 scan recipes for duplicates</button>
<p class="muted" style="margin-top:8px;">walks your household recipes, clusters by name similarity, asks sonnet which clusters are real dupes. apply path uses Mealie's DELETE endpoint — irreversible. user-confirms per cluster.</p>
<p class="muted" style="margin-top:8px;">walks your household recipes, clusters by name similarity, asks sage which clusters are real dupes. apply path uses Mealie's DELETE endpoint — irreversible. user-confirms per cluster.</p>
</div>
<div id="progress-pane" style="display:none;">
@ -244,7 +244,7 @@
meta.className = 'cluster-meta';
meta.textContent = dec.duplicates
? `delete ${(dec.delete_slugs||[]).length}, keep 1`
: `keep all ${cluster.length} (sonnet says distinct)`;
: `keep all ${cluster.length} (sage says distinct)`;
left.appendChild(nm); left.appendChild(meta);
const tog = document.createElement('button');

View file

@ -23,7 +23,7 @@
<div class="crumb">// enrich · per-recipe metadata for smarter planning</div>
<h1>recipe <span class="accent">enrich</span></h1>
<div class="lede">
walk every household recipe and have sonnet generate structured metadata —
walk every household recipe and have sage generate structured metadata —
cuisine, complexity, macros, meal type, primary protein + carb,
comfort tier, one-line summary. the plan generator uses this so
"high protein week" actually filters the pool, not just biases the vibe.

View file

@ -57,13 +57,13 @@
</div>
<p class="muted">mealie's parser is per-recipe; this kicks off a bulk pass over your whole library. review proposals, apply the good ones.</p>
<p><a class="btn" href="/sterilize">🪄 bulk sterilize recipes →</a></p>
<p class="muted" style="margin-top:14px;">scan your foods table for dupes, ask sonnet to pick canonicals, merge in mealie. one-time cleanup; aliases get attached to the survivors so the parser fuzzy-matches variants from now on.</p>
<p class="muted" style="margin-top:14px;">scan your foods table for dupes, ask sage to pick canonicals, merge in mealie. one-time cleanup; aliases get attached to the survivors so the parser fuzzy-matches variants from now on.</p>
<p><a class="btn" href="/consolidate">🔮 consolidate foods table →</a></p>
<p class="muted" style="margin-top:14px;">find duplicate recipes by name + ingredient similarity. sonnet picks the canonical to keep; you confirm per cluster before mealie deletes the others. permanent — review carefully.</p>
<p class="muted" style="margin-top:14px;">find duplicate recipes by name + ingredient similarity. sage picks the canonical to keep; you confirm per cluster before mealie deletes the others. permanent — review carefully.</p>
<p><a class="btn" href="/dedupe-recipes">🌀 dedupe recipes →</a></p>
<p class="muted" style="margin-top:14px;">have sonnet generate per-recipe metadata — cuisine, complexity, macros, primary protein/carb, comfort tier, summary. the plan generator reads this so "high protein week" is a real query, not just a vibe.</p>
<p class="muted" style="margin-top:14px;">have sage generate per-recipe metadata — cuisine, complexity, macros, primary protein/carb, comfort tier, summary. the plan generator reads this so "high protein week" is a real query, not just a vibe.</p>
<p><a class="btn" href="/enrich-recipes">✨ enrich recipes →</a></p>
</section>
{% endif %}

View file

@ -242,6 +242,38 @@
border-color: rgba(232, 96, 106, .3);
color: var(--crit);
}
.meals-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
.meal-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
color: var(--bone-dim);
font-family: var(--sans); font-size: .9em;
cursor: pointer; user-select: none;
min-height: 32px;
}
.meal-chip input { margin: 0; cursor: pointer; }
.meal-chip:has(input:checked) {
background: var(--purple-deep);
border-color: var(--purple-dim);
color: var(--purple-bright);
}
.meal-row {
display: flex; flex-direction: column; gap: 6px;
padding: 8px 0;
border-top: 1px solid var(--line-soft);
}
.meal-row:first-child { border-top: 0; }
.meal-tag {
display: inline-block;
color: var(--purple); font-family: var(--mono);
font-size: 9px; letter-spacing: .2em; text-transform: uppercase;
margin-bottom: 2px;
}
</style>
<div class="page-head">
@ -305,10 +337,17 @@
<div class="pref-readout">excluding: <em>{{ plan.exclusions_label }}</em></div>
{% endif %}
{% elif not plan.slots %}
<p>no plan yet. summon claude to build one from the {{ pick_count }} pinned pick{{ '' if pick_count == 1 else 's' }} + the rest of the grimoire.</p>
<p>no plan yet. summon sage to build one from the {{ pick_count }} pinned pick{{ '' if pick_count == 1 else 's' }} + the rest of the grimoire.</p>
<div class="pref-block">
<label class="pref-label" for="pref-input">this week's vibe (optional)</label>
<label class="pref-label">meals to plan</label>
<div class="meals-row">
<label class="meal-chip"><input type="checkbox" class="meal-check" value="breakfast" {% if 'breakfast' in plan.meal_types_list %}checked{% endif %}> 🍳 breakfast</label>
<label class="meal-chip"><input type="checkbox" class="meal-check" value="lunch" {% if 'lunch' in plan.meal_types_list %}checked{% endif %}> 🥪 lunch</label>
<label class="meal-chip"><input type="checkbox" class="meal-check" value="dinner" {% if 'dinner' in plan.meal_types_list %}checked{% endif %}> 🍽️ dinner</label>
</div>
<label class="pref-label" for="pref-input" style="margin-top:14px;">this week's vibe (optional)</label>
<textarea id="pref-input" class="pref-input" rows="2" maxlength="1000" placeholder="e.g. high protein low carb · carb load · light recovery week · no fish · vegetarian-leaning · spicy comfort food">{{ plan.preference_prompt or '' }}</textarea>
<div class="pref-presets">
<button type="button" class="pref-chip" onclick="setPref('high protein, low carb — lean and gym-friendly')">🥩 high protein</button>
@ -373,7 +412,7 @@
</div>
<button class="gen-cta" type="button" id="gen-btn" onclick="generatePlan(this)">🪄 generate this week's plan</button>
<div class="gen-meta" id="gen-meta">sonnet · ~30s</div>
<div class="gen-meta" id="gen-meta">sage · ~30s</div>
{% else %}
<p>plan's set.</p>
{% if plan.preference_prompt %}
@ -400,23 +439,36 @@
<section class="panel purple">
<div class="panel-head">
<h2>the week</h2>
<span class="ctx">{{ plan.slots|length }} day{{ '' if plan.slots|length == 1 else 's' }}</span>
<span class="ctx">{{ plan.slots|length }} slot{{ '' if plan.slots|length == 1 else 's' }} · {{ plan.meal_types_list|join(' + ') }}</span>
</div>
{# Group slots by day, then render each meal_type within the day card #}
{% set days_order = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'] %}
{% set meals_by_day = {} %}
{% for s in plan.slots %}
{% if s.day not in meals_by_day %}{% set _ = meals_by_day.update({s.day: []}) %}{% endif %}
{% set _ = meals_by_day[s.day].append(s) %}
{% endfor %}
<div class="day-grid">
{% for s in plan.slots %}
<a class="day-card recipe-card {% if s.picker_subs %}from-pick{% endif %}" href="/recipes/{{ s.recipe_slug }}" data-slug="{{ s.recipe_slug }}" data-name="{{ s.recipe_name }}">
<div class="dlabel">{{ s.day }}</div>
<div class="rname">{{ s.recipe_name }}</div>
{% if s.picker_subs %}
<div class="pickers">
{% for sub in s.picker_subs %}
<span class="pchip">🍄 {{ sub_display.get(sub, 'family') }}</span>
{% for d in days_order %}{% if d in meals_by_day %}
{% set day_slots = meals_by_day[d] %}
<div class="day-card {% if day_slots|selectattr('picker_subs')|list %}from-pick{% endif %}">
<div class="dlabel">{{ d }}</div>
{% for s in day_slots %}
<div class="meal-row">
{% if (plan.meal_types_list|length) > 1 %}<div class="meal-tag">{{ s.meal_type or 'dinner' }}</div>{% endif %}
<a class="recipe-card rname" href="/recipes/{{ s.recipe_slug }}" data-slug="{{ s.recipe_slug }}" data-name="{{ s.recipe_name }}">{{ s.recipe_name }}</a>
{% if s.picker_subs %}
<div class="pickers">
{% for sub in s.picker_subs %}
<span class="pchip">🍄 {{ sub_display.get(sub, 'family') }}</span>
{% endfor %}
</div>
{% endif %}
{% if s.reason %}<div class="reason">{{ s.reason }}</div>{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if s.reason %}<div class="reason">{{ s.reason }}</div>{% endif %}
</a>
{% endfor %}
{% endif %}{% endfor %}
</div>
</section>
{% endif %}
@ -510,11 +562,18 @@ function readExclusions() {
return out;
}
function readMealTypes() {
const out = [];
document.querySelectorAll('.meal-check:checked').forEach(el => out.push(el.value));
// Default to dinner if nothing checked — never send empty
return out.length ? out : ['dinner'];
}
async function generatePlan(btn) {
btn.disabled = true;
btn.innerHTML = '🪄 summoning…';
const meta = document.getElementById('gen-meta');
if (meta) meta.textContent = 'sonnet building plan — hold tight';
if (meta) meta.textContent = 'sage building plan — hold tight';
try {
const r = await fetch('/api/plan/generate', {
method: 'POST',
@ -524,6 +583,7 @@ async function generatePlan(btn) {
preference: readPref(),
targets: readTargets(),
exclusions: readExclusions(),
meal_types: readMealTypes(),
}),
});
if (r.status === 409) {

View file

@ -106,7 +106,7 @@
<h1>bulk <span class="accent">sterilize</span></h1>
<div class="lede">
walk every recipe in the household, send the unparsed ingredients to
sonnet, review the proposals, apply the good ones. mealie's food
sage, review the proposals, apply the good ones. mealie's food
table gets cleaner; the shopping list math gets sharper.
</div>
</div>
@ -378,7 +378,7 @@
<tbody>${rows}</tbody>`;
card.appendChild(tbl);
} else {
// All rows were identity matches — sonnet thinks this recipe is
// All rows were identity matches — sage thinks this recipe is
// already clean. Show a marker; user can still skip/approve as
// a no-op apply (which will be cheap, just refreshes food.id
// resolution if any food row got renamed in Mealie).