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:
parent
b4cb48bef8
commit
a6a28ef6e4
9 changed files with 260 additions and 57 deletions
|
|
@ -470,6 +470,33 @@ MIGRATIONS = [
|
||||||
ALTER TABLE cauldron_meal_plans
|
ALTER TABLE cauldron_meal_plans
|
||||||
ADD COLUMN IF NOT EXISTS exclusions_json JSON
|
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),
|
(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(
|
def set_plan_targets_and_exclusions(
|
||||||
self,
|
self,
|
||||||
plan_id: int,
|
plan_id: int,
|
||||||
|
|
@ -779,14 +827,16 @@ class DB:
|
||||||
# in calendar order.
|
# in calendar order.
|
||||||
PLAN_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
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]:
|
def list_plan_slots(self, plan_id: int) -> list[dict]:
|
||||||
"""All slots for a plan, ordered Mon..Sun. picker_subs is decoded
|
"""All slots for a plan, ordered Mon..Sun then breakfast→dinner.
|
||||||
from JSON to a list (or [] if null)."""
|
picker_subs is decoded from JSON to a list (or [] if null)."""
|
||||||
import json as _json
|
import json as _json
|
||||||
with self.conn() as c, c.cursor() as cur:
|
with self.conn() as c, c.cursor() as cur:
|
||||||
cur.execute(
|
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
|
picker_subs, reason, notes, created_at
|
||||||
FROM cauldron_meal_plan_slots
|
FROM cauldron_meal_plan_slots
|
||||||
WHERE plan_id = %s
|
WHERE plan_id = %s
|
||||||
|
|
@ -809,30 +859,39 @@ class DB:
|
||||||
r["notes"] = _json.loads(n)
|
r["notes"] = _json.loads(n)
|
||||||
except Exception:
|
except Exception:
|
||||||
r["notes"] = None
|
r["notes"] = None
|
||||||
order = {d: i for i, d in enumerate(self.PLAN_DAYS)}
|
day_order = {d: i for i, d in enumerate(self.PLAN_DAYS)}
|
||||||
rows.sort(key=lambda r: order.get((r.get("day") or "").lower(), 99))
|
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
|
return rows
|
||||||
|
|
||||||
def save_plan_slots(self, plan_id: int, slots: list[dict]) -> int:
|
def save_plan_slots(self, plan_id: int, slots: list[dict]) -> int:
|
||||||
"""INSERT IGNORE every slot. Returns count actually inserted —
|
"""INSERT IGNORE every slot. Returns count actually inserted —
|
||||||
callers can use this to detect race contention (zero rows = someone
|
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
|
import json as _json
|
||||||
if not slots:
|
if not slots:
|
||||||
return 0
|
return 0
|
||||||
inserted = 0
|
inserted = 0
|
||||||
with self.conn() as c, c.cursor() as cur:
|
with self.conn() as c, c.cursor() as cur:
|
||||||
for s in slots:
|
for s in slots:
|
||||||
|
meal = (s.get("meal_type") or "dinner").lower()
|
||||||
|
if meal not in self.MEAL_ORDER:
|
||||||
|
meal = "dinner"
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT IGNORE INTO cauldron_meal_plan_slots
|
INSERT IGNORE INTO cauldron_meal_plan_slots
|
||||||
(plan_id, day, recipe_slug, recipe_name, source,
|
(plan_id, day, meal_type, recipe_slug, recipe_name,
|
||||||
picker_subs, reason, notes)
|
source, picker_subs, reason, notes)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
plan_id,
|
plan_id,
|
||||||
(s.get("day") or "").lower()[:10],
|
(s.get("day") or "").lower()[:10],
|
||||||
|
meal,
|
||||||
s["recipe_slug"][:255],
|
s["recipe_slug"][:255],
|
||||||
(s.get("recipe_name") or s["recipe_slug"])[:500],
|
(s.get("recipe_name") or s["recipe_slug"])[:500],
|
||||||
s.get("source") or ("pick" if s.get("picker_subs") else "mealie"),
|
s.get("source") or ("pick" if s.get("picker_subs") else "mealie"),
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ class Forge:
|
||||||
picker_profiles: dict | None = None,
|
picker_profiles: dict | None = None,
|
||||||
daily_targets: dict | None = None,
|
daily_targets: dict | None = None,
|
||||||
exclusions: list | None = None,
|
exclusions: list | None = None,
|
||||||
|
meal_types: list | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
||||||
|
|
@ -111,17 +112,37 @@ class Forge:
|
||||||
if slug:
|
if slug:
|
||||||
valid_by_slug.setdefault(slug, p.get("name") or 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(
|
prompt = self._build_plan_prompt(
|
||||||
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
|
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
|
||||||
preference=preference, picker_profiles=picker_profiles,
|
preference=preference, picker_profiles=picker_profiles,
|
||||||
daily_targets=daily_targets, exclusions=exclusions,
|
daily_targets=daily_targets, exclusions=exclusions,
|
||||||
|
meal_types=meal_types_clean,
|
||||||
)
|
)
|
||||||
result = self.run(prompt, model=model or "sonnet")
|
result = self.run(prompt, model=model or "sonnet")
|
||||||
parsed = _extract_plan_slots(result)
|
parsed = _extract_plan_slots(result)
|
||||||
if not isinstance(parsed, list):
|
if not isinstance(parsed, list):
|
||||||
raise ForgeError("model output: 'slots' must be a list")
|
raise ForgeError("model output: 'slots' must be a list")
|
||||||
if len(parsed) != slots:
|
if len(parsed) != expected_total:
|
||||||
raise ForgeError(f"model output: got {len(parsed)} slots, expected {slots}")
|
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 attribution lookup keyed by slug → list[sub]
|
||||||
pick_subs_by_slug: dict[str, list[str]] = {}
|
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 [])
|
pick_subs_by_slug[slug] = list(p.get("picker_subs") or [])
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
seen_days: set[str] = set()
|
seen: set[tuple[str, str]] = set() # (day, meal_type) uniqueness
|
||||||
for raw in parsed:
|
for raw in parsed:
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
raise ForgeError("model output: each slot must be an object")
|
raise ForgeError("model output: each slot must be an object")
|
||||||
day = (raw.get("day") or "").strip().lower()
|
day = (raw.get("day") or "").strip().lower()
|
||||||
|
meal = (raw.get("meal_type") or "dinner").strip().lower()
|
||||||
slug = (raw.get("recipe_slug") or "").strip()
|
slug = (raw.get("recipe_slug") or "").strip()
|
||||||
if day not in _DAYS:
|
if day not in _DAYS:
|
||||||
raise ForgeError(f"model output: bad day '{day}'")
|
raise ForgeError(f"model output: bad day '{day}'")
|
||||||
if day in seen_days:
|
if meal not in meal_types_clean:
|
||||||
raise ForgeError(f"model output: duplicate day '{day}'")
|
raise ForgeError(f"model output: unexpected meal_type '{meal}' (expected one of {meal_types_clean})")
|
||||||
seen_days.add(day)
|
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:
|
if not slug or slug not in valid_by_slug:
|
||||||
raise ForgeError(f"model output: unknown recipe_slug '{slug}'")
|
raise ForgeError(f"model output: unknown recipe_slug '{slug}'")
|
||||||
|
|
||||||
|
|
@ -158,6 +183,7 @@ class Forge:
|
||||||
|
|
||||||
out.append({
|
out.append({
|
||||||
"day": day,
|
"day": day,
|
||||||
|
"meal_type": meal,
|
||||||
"recipe_slug": slug,
|
"recipe_slug": slug,
|
||||||
"recipe_name": valid_by_slug[slug],
|
"recipe_name": valid_by_slug[slug],
|
||||||
"picker_subs": picker_subs,
|
"picker_subs": picker_subs,
|
||||||
|
|
@ -169,7 +195,7 @@ class Forge:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None,
|
def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None,
|
||||||
picker_profiles=None, daily_targets=None,
|
picker_profiles=None, daily_targets=None,
|
||||||
exclusions=None) -> str:
|
exclusions=None, meal_types=None) -> str:
|
||||||
pool_lines = []
|
pool_lines = []
|
||||||
for r in recipes:
|
for r in recipes:
|
||||||
slug = r.get("slug") or ""
|
slug = r.get("slug") or ""
|
||||||
|
|
@ -364,29 +390,61 @@ class Forge:
|
||||||
"fill the remaining slots.\n"
|
"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 (
|
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"for the week of {week_start}.\n\n"
|
||||||
f"POOL (all available recipes):\n{pool_block}\n\n"
|
f"POOL (all available recipes):\n{pool_block}\n\n"
|
||||||
f"PICKS (recipes the family pre-selected — every pick MUST appear "
|
f"PICKS (recipes the family pre-selected — every pick MUST appear in "
|
||||||
f"if pool size >= {slots}; no repeats unless pool < {slots}):\n"
|
f"some appropriate slot if pool size allows; no repeats):\n"
|
||||||
f"{picks_block}\n"
|
f"{picks_block}\n"
|
||||||
f"{profile_block}"
|
f"{profile_block}"
|
||||||
|
f"{meal_block}"
|
||||||
f"{pref_block}"
|
f"{pref_block}"
|
||||||
f"{targets_block}"
|
f"{targets_block}"
|
||||||
f"{excl_block}"
|
f"{excl_block}"
|
||||||
"Output JSON ONLY, no prose: "
|
f"Output JSON ONLY, no prose: {output_shape}\n\n"
|
||||||
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
|
||||||
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
|
|
||||||
"Rules:\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"
|
"- Distribute picks evenly across the week — don't bunch them\n"
|
||||||
"- ROTATION: prefer recipes with `last:NNw-ago` further in the past "
|
"- 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 "
|
"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 "
|
"(`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"
|
"pick. Never repeat the same recipe slug within this plan.\n"
|
||||||
"- VARIETY: don't fill 5 of 7 slots with the same primary_protein or "
|
"- VARIETY: don't fill 5 of 7 dinner slots with the same primary_protein "
|
||||||
"the same cuisine. Mix it up across the week.\n"
|
"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 "
|
"- \"reason\" is a one-line user-facing rationale "
|
||||||
"(e.g., \"balances heavy and light meals\", \"honors abby's pick\", "
|
"(e.g., \"balances heavy and light meals\", \"honors abby's pick\", "
|
||||||
"\"high-protein lean — pairs with the gym week\", "
|
"\"high-protein lean — pairs with the gym week\", "
|
||||||
|
|
|
||||||
|
|
@ -617,7 +617,7 @@ def create_app() -> Flask:
|
||||||
pick_count = len(db.list_household_pick_slugs(hid))
|
pick_count = len(db.list_household_pick_slugs(hid))
|
||||||
|
|
||||||
# Decode JSON columns for the template + build readout labels
|
# 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)
|
v = plan.get(k)
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -626,6 +626,9 @@ def create_app() -> Flask:
|
||||||
plan[k] = None
|
plan[k] = None
|
||||||
targets = plan.get("daily_targets_json") if isinstance(plan.get("daily_targets_json"), dict) else 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
|
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"] = ""
|
plan["targets_label"] = ""
|
||||||
if targets:
|
if targets:
|
||||||
bits = []
|
bits = []
|
||||||
|
|
@ -761,12 +764,16 @@ def create_app() -> Flask:
|
||||||
plan["preference_prompt"] = preference[:1000]
|
plan["preference_prompt"] = preference[:1000]
|
||||||
targets_body = body.get("targets") if isinstance(body.get("targets"), dict) else None
|
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
|
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:
|
if targets_body is not None or exclusions_body is not None:
|
||||||
db.set_plan_targets_and_exclusions(
|
db.set_plan_targets_and_exclusions(
|
||||||
plan["id"],
|
plan["id"],
|
||||||
targets=targets_body,
|
targets=targets_body,
|
||||||
exclusions=exclusions_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
|
# Re-fetch so the local plan dict has the persisted values
|
||||||
plan = db.get_or_create_plan(hid, this_monday)
|
plan = db.get_or_create_plan(hid, this_monday)
|
||||||
|
|
||||||
|
|
@ -834,7 +841,8 @@ def create_app() -> Flask:
|
||||||
# the household's collective average.
|
# the household's collective average.
|
||||||
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
|
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")
|
plan_targets = plan.get("daily_targets_json")
|
||||||
if isinstance(plan_targets, str):
|
if isinstance(plan_targets, str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -847,6 +855,12 @@ def create_app() -> Flask:
|
||||||
plan_exclusions = _json_loads(plan_exclusions)
|
plan_exclusions = _json_loads(plan_exclusions)
|
||||||
except Exception:
|
except Exception:
|
||||||
plan_exclusions = None
|
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:
|
try:
|
||||||
slots = forge.generate_plan(
|
slots = forge.generate_plan(
|
||||||
|
|
@ -856,6 +870,7 @@ def create_app() -> Flask:
|
||||||
picker_profiles=picker_profiles,
|
picker_profiles=picker_profiles,
|
||||||
daily_targets=plan_targets if isinstance(plan_targets, dict) else None,
|
daily_targets=plan_targets if isinstance(plan_targets, dict) else None,
|
||||||
exclusions=plan_exclusions if isinstance(plan_exclusions, list) 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:
|
except ForgeError as e:
|
||||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
@ -909,12 +924,16 @@ def create_app() -> Flask:
|
||||||
plan["preference_prompt"] = preference[:1000]
|
plan["preference_prompt"] = preference[:1000]
|
||||||
targets_body = body.get("targets") if isinstance(body.get("targets"), dict) else None
|
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
|
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:
|
if targets_body is not None or exclusions_body is not None:
|
||||||
db.set_plan_targets_and_exclusions(
|
db.set_plan_targets_and_exclusions(
|
||||||
plan["id"],
|
plan["id"],
|
||||||
targets=targets_body,
|
targets=targets_body,
|
||||||
exclusions=exclusions_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)
|
plan = db.get_or_create_plan(hid, this_monday)
|
||||||
|
|
||||||
# Now fall through to the same logic as generate
|
# Now fall through to the same logic as generate
|
||||||
|
|
@ -942,7 +961,7 @@ def create_app() -> Flask:
|
||||||
# the household's collective average.
|
# the household's collective average.
|
||||||
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
|
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")
|
plan_targets = plan.get("daily_targets_json")
|
||||||
if isinstance(plan_targets, str):
|
if isinstance(plan_targets, str):
|
||||||
try:
|
try:
|
||||||
|
|
@ -955,6 +974,12 @@ def create_app() -> Flask:
|
||||||
plan_exclusions = _json_loads(plan_exclusions)
|
plan_exclusions = _json_loads(plan_exclusions)
|
||||||
except Exception:
|
except Exception:
|
||||||
plan_exclusions = None
|
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:
|
try:
|
||||||
slots = forge.generate_plan(
|
slots = forge.generate_plan(
|
||||||
|
|
@ -964,6 +989,7 @@ def create_app() -> Flask:
|
||||||
picker_profiles=picker_profiles,
|
picker_profiles=picker_profiles,
|
||||||
daily_targets=plan_targets if isinstance(plan_targets, dict) else None,
|
daily_targets=plan_targets if isinstance(plan_targets, dict) else None,
|
||||||
exclusions=plan_exclusions if isinstance(plan_exclusions, list) 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:
|
except ForgeError as e:
|
||||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
<h1>foods <span class="accent">consolidate</span></h1>
|
<h1>foods <span class="accent">consolidate</span></h1>
|
||||||
<div class="lede">
|
<div class="lede">
|
||||||
scan your mealie household foods, cluster lookalikes by similarity,
|
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.
|
rewrites every recipe ingredient to point at the canonical row.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
<p>no run yet. kick one off?</p>
|
<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>
|
<button class="btn btn-purple" id="start-btn" type="button" onclick="startRun()">🪄 scan + cluster mealie foods</button>
|
||||||
<p class="muted" style="margin-top:8px;">
|
<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.
|
first run might take 5-15 min depending on cluster count.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@
|
||||||
<div class="crumb">// dedupe · find duplicate recipes</div>
|
<div class="crumb">// dedupe · find duplicate recipes</div>
|
||||||
<h1>recipe <span class="accent">dedupe</span></h1>
|
<h1>recipe <span class="accent">dedupe</span></h1>
|
||||||
<div class="lede">
|
<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
|
looks at ingredients + step counts + source URLs to decide whether
|
||||||
similar-named recipes are actually the same dish. you confirm per
|
similar-named recipes are actually the same dish. you confirm per
|
||||||
cluster — DELETE in Mealie is permanent.
|
cluster — DELETE in Mealie is permanent.
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
<div id="empty-pane" style="display:none;">
|
<div id="empty-pane" style="display:none;">
|
||||||
<p>no run yet. scan now?</p>
|
<p>no run yet. scan now?</p>
|
||||||
<button class="btn btn-purple" id="start-btn" type="button" onclick="startRun()">🪄 scan recipes for duplicates</button>
|
<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>
|
||||||
|
|
||||||
<div id="progress-pane" style="display:none;">
|
<div id="progress-pane" style="display:none;">
|
||||||
|
|
@ -244,7 +244,7 @@
|
||||||
meta.className = 'cluster-meta';
|
meta.className = 'cluster-meta';
|
||||||
meta.textContent = dec.duplicates
|
meta.textContent = dec.duplicates
|
||||||
? `delete ${(dec.delete_slugs||[]).length}, keep 1`
|
? `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);
|
left.appendChild(nm); left.appendChild(meta);
|
||||||
|
|
||||||
const tog = document.createElement('button');
|
const tog = document.createElement('button');
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<div class="crumb">// enrich · per-recipe metadata for smarter planning</div>
|
<div class="crumb">// enrich · per-recipe metadata for smarter planning</div>
|
||||||
<h1>recipe <span class="accent">enrich</span></h1>
|
<h1>recipe <span class="accent">enrich</span></h1>
|
||||||
<div class="lede">
|
<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,
|
cuisine, complexity, macros, meal type, primary protein + carb,
|
||||||
comfort tier, one-line summary. the plan generator uses this so
|
comfort tier, one-line summary. the plan generator uses this so
|
||||||
"high protein week" actually filters the pool, not just biases the vibe.
|
"high protein week" actually filters the pool, not just biases the vibe.
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,13 @@
|
||||||
</div>
|
</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 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><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><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><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>
|
<p><a class="btn" href="/enrich-recipes">✨ enrich recipes →</a></p>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,38 @@
|
||||||
border-color: rgba(232, 96, 106, .3);
|
border-color: rgba(232, 96, 106, .3);
|
||||||
color: var(--crit);
|
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>
|
</style>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
|
|
@ -305,10 +337,17 @@
|
||||||
<div class="pref-readout">excluding: <em>{{ plan.exclusions_label }}</em></div>
|
<div class="pref-readout">excluding: <em>{{ plan.exclusions_label }}</em></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif not plan.slots %}
|
{% 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">
|
<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>
|
<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">
|
<div class="pref-presets">
|
||||||
<button type="button" class="pref-chip" onclick="setPref('high protein, low carb — lean and gym-friendly')">🥩 high protein</button>
|
<button type="button" class="pref-chip" onclick="setPref('high protein, low carb — lean and gym-friendly')">🥩 high protein</button>
|
||||||
|
|
@ -373,7 +412,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="gen-cta" type="button" id="gen-btn" onclick="generatePlan(this)">🪄 generate this week's plan</button>
|
<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 %}
|
{% else %}
|
||||||
<p>plan's set.</p>
|
<p>plan's set.</p>
|
||||||
{% if plan.preference_prompt %}
|
{% if plan.preference_prompt %}
|
||||||
|
|
@ -400,23 +439,36 @@
|
||||||
<section class="panel purple">
|
<section class="panel purple">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>the week</h2>
|
<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>
|
</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">
|
<div class="day-grid">
|
||||||
{% for s in plan.slots %}
|
{% for d in days_order %}{% if d in meals_by_day %}
|
||||||
<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 }}">
|
{% set day_slots = meals_by_day[d] %}
|
||||||
<div class="dlabel">{{ s.day }}</div>
|
<div class="day-card {% if day_slots|selectattr('picker_subs')|list %}from-pick{% endif %}">
|
||||||
<div class="rname">{{ s.recipe_name }}</div>
|
<div class="dlabel">{{ d }}</div>
|
||||||
{% if s.picker_subs %}
|
{% for s in day_slots %}
|
||||||
<div class="pickers">
|
<div class="meal-row">
|
||||||
{% for sub in s.picker_subs %}
|
{% if (plan.meal_types_list|length) > 1 %}<div class="meal-tag">{{ s.meal_type or 'dinner' }}</div>{% endif %}
|
||||||
<span class="pchip">🍄 {{ sub_display.get(sub, 'family') }}</span>
|
<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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}{% endfor %}
|
||||||
{% if s.reason %}<div class="reason">{{ s.reason }}</div>{% endif %}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -510,11 +562,18 @@ function readExclusions() {
|
||||||
return out;
|
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) {
|
async function generatePlan(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '🪄 summoning…';
|
btn.innerHTML = '🪄 summoning…';
|
||||||
const meta = document.getElementById('gen-meta');
|
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 {
|
try {
|
||||||
const r = await fetch('/api/plan/generate', {
|
const r = await fetch('/api/plan/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -524,6 +583,7 @@ async function generatePlan(btn) {
|
||||||
preference: readPref(),
|
preference: readPref(),
|
||||||
targets: readTargets(),
|
targets: readTargets(),
|
||||||
exclusions: readExclusions(),
|
exclusions: readExclusions(),
|
||||||
|
meal_types: readMealTypes(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (r.status === 409) {
|
if (r.status === 409) {
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
<h1>bulk <span class="accent">sterilize</span></h1>
|
<h1>bulk <span class="accent">sterilize</span></h1>
|
||||||
<div class="lede">
|
<div class="lede">
|
||||||
walk every recipe in the household, send the unparsed ingredients to
|
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.
|
table gets cleaner; the shopping list math gets sharper.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -378,7 +378,7 @@
|
||||||
<tbody>${rows}</tbody>`;
|
<tbody>${rows}</tbody>`;
|
||||||
card.appendChild(tbl);
|
card.appendChild(tbl);
|
||||||
} else {
|
} 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
|
// already clean. Show a marker; user can still skip/approve as
|
||||||
// a no-op apply (which will be cheap, just refreshes food.id
|
// a no-op apply (which will be cheap, just refreshes food.id
|
||||||
// resolution if any food row got renamed in Mealie).
|
// resolution if any food row got renamed in Mealie).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue