plan UI: numeric daily macro targets + allergen exclusions
The next-tier of structured planning constraints, building on the
free-form preference textarea. Now the household can set hard rules
like '2200 cal/day, 150g protein, no dairy or shellfish' and the
planner enforces them.
Schema (migrations 026 + 027):
- daily_targets_json on cauldron_meal_plans: {calories?, protein_g?,
carbs_g?, fat_g?} per-day budget. Sonnet sums per-recipe macros
across the 7-day plan and aims for ±15% of the weekly total.
- exclusions_json on cauldron_meal_plans: list of contains.* keys
(subset of {dairy, gluten, nuts, peanuts, eggs, shellfish, fish,
soy, sesame, pork}). Hard filter on AI-chosen slots; picks still
apply but get flagged with the conflict in their reason field.
DB:
- set_plan_targets_and_exclusions normalizes inputs (drops zeros,
validates against allowed enum values, lowercases).
- The plan_view path decodes the JSON columns server-side and adds
display labels (`targets_label`, `exclusions_label`) and the parsed
dict/list (`targets_dict`, `exclusions_list`) for the template.
Forge:
- generate_plan accepts daily_targets dict + exclusions list.
- New DAILY MACRO TARGETS prompt block — instructs Sonnet to sum
per-serving macros across all 7 slots and land within ±15% of
weekly totals; tradeoff slots when needed.
- New STRICT EXCLUSIONS prompt block — recipes whose has: list
intersects exclusions are forbidden in AI-chosen slots. Picks that
conflict still appear (explicit user choice) but get flagged in
the slot reason ("contains dairy — household pick").
Server:
- /api/plan/generate accepts {preference, targets, exclusions}.
Persists all three before kicking off Sonnet so re-rolls reuse them.
- /api/plan/regenerate same — empty body reuses persisted constraints.
UI (/plan):
- New <details> section "numeric targets + allergen exclusions
(optional)" tucked under the existing vibe textarea + presets.
- 4 numeric inputs (cal/protein/carbs/fat per day) with quick-set
preset chips: balanced 2200 / protein lean 2400 / carb load 2600 /
cut 1800 / clear.
- 10 allergen checkbox chips with red-pill styling when checked
(uses CSS :has(input:checked) — modern browsers).
- Hydration: persisted targets pre-fill the inputs, persisted
exclusions pre-check the boxes.
- After generation, three readouts above the action buttons show
active vibe / macros / exclusions.
Cobb's exact ask "2200 cal/day, protein and carb balanced, no dairy"
now maps to: targets={calories: 2200, protein_g: 150, carbs_g: 250}
+ exclusions=[dairy] + free-form preference (optional). All persist.
Re-rolls iterate within those constraints automatically.
This commit is contained in:
parent
4db447edad
commit
07dab10c4b
4 changed files with 371 additions and 9 deletions
|
|
@ -454,6 +454,22 @@ MIGRATIONS = [
|
||||||
FOREIGN KEY (started_by_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE
|
FOREIGN KEY (started_by_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
""",
|
""",
|
||||||
|
# 026 — Per-week structured planning constraints. daily_targets_json
|
||||||
|
# holds the macro budget the household wants to hit per day
|
||||||
|
# ({calories, protein_g, carbs_g, fat_g} — Sonnet sums per slot and
|
||||||
|
# divides by 7). exclusions_json is a list of contains.* allergen
|
||||||
|
# keys to exclude entirely from the AI-chosen slots
|
||||||
|
# (["dairy", "shellfish"]). Both nullable; the existing free-form
|
||||||
|
# preference_prompt is kept for vibe-only weeks where macros aren't
|
||||||
|
# explicit.
|
||||||
|
"""
|
||||||
|
ALTER TABLE cauldron_meal_plans
|
||||||
|
ADD COLUMN IF NOT EXISTS daily_targets_json JSON
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE cauldron_meal_plans
|
||||||
|
ADD COLUMN IF NOT EXISTS exclusions_json JSON
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -661,6 +677,52 @@ class DB:
|
||||||
(clean, plan_id),
|
(clean, plan_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def set_plan_targets_and_exclusions(
|
||||||
|
self,
|
||||||
|
plan_id: int,
|
||||||
|
*,
|
||||||
|
targets: dict | None,
|
||||||
|
exclusions: list | None,
|
||||||
|
) -> None:
|
||||||
|
"""Persist the per-week macro targets ({calories, protein_g, carbs_g,
|
||||||
|
fat_g} — null fields mean 'no target') and allergen exclusion list
|
||||||
|
(subset of contains.* keys). Either or both can be None."""
|
||||||
|
import json as _json
|
||||||
|
# Normalize: drop empty/zero values so the prompt only renders the
|
||||||
|
# ones that matter
|
||||||
|
clean_targets: dict | None = None
|
||||||
|
if isinstance(targets, dict):
|
||||||
|
ct = {}
|
||||||
|
for k in ("calories", "protein_g", "carbs_g", "fat_g"):
|
||||||
|
v = targets.get(k)
|
||||||
|
try:
|
||||||
|
n = int(v) if v not in (None, "", 0) else 0
|
||||||
|
if n > 0:
|
||||||
|
ct[k] = n
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
clean_targets = ct or None
|
||||||
|
clean_exclusions: list | None = None
|
||||||
|
if isinstance(exclusions, list):
|
||||||
|
allowed = {"dairy", "gluten", "nuts", "peanuts", "eggs",
|
||||||
|
"shellfish", "fish", "soy", "sesame", "pork"}
|
||||||
|
ce = sorted({
|
||||||
|
str(x).strip().lower() for x in exclusions
|
||||||
|
if isinstance(x, str) and x.strip().lower() in allowed
|
||||||
|
})
|
||||||
|
clean_exclusions = ce or None
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE cauldron_meal_plans
|
||||||
|
SET daily_targets_json=%s, exclusions_json=%s
|
||||||
|
WHERE id=%s""",
|
||||||
|
(
|
||||||
|
_json.dumps(clean_targets) if clean_targets else None,
|
||||||
|
_json.dumps(clean_exclusions) if clean_exclusions else None,
|
||||||
|
plan_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def lock_plan(self, plan_id: int, *, sub: str, reason: str = "user") -> dict:
|
def lock_plan(self, plan_id: int, *, sub: str, reason: str = "user") -> dict:
|
||||||
"""Lock a plan if not already locked. Returns updated plan dict."""
|
"""Lock a plan if not already locked. Returns updated plan dict."""
|
||||||
with self.conn() as c, c.cursor() as cur:
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ class Forge:
|
||||||
week_start: str,
|
week_start: str,
|
||||||
preference: str | None = None,
|
preference: str | None = None,
|
||||||
picker_profiles: dict | None = None,
|
picker_profiles: dict | None = None,
|
||||||
|
daily_targets: dict | None = None,
|
||||||
|
exclusions: 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
|
||||||
|
|
@ -112,6 +114,7 @@ class Forge:
|
||||||
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,
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
|
@ -165,7 +168,8 @@ 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) -> str:
|
picker_profiles=None, daily_targets=None,
|
||||||
|
exclusions=None) -> str:
|
||||||
pool_lines = []
|
pool_lines = []
|
||||||
for r in recipes:
|
for r in recipes:
|
||||||
slug = r.get("slug") or ""
|
slug = r.get("slug") or ""
|
||||||
|
|
@ -299,6 +303,52 @@ class Forge:
|
||||||
"Picks still take precedence over profile bias.\n"
|
"Picks still take precedence over profile bias.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Numeric daily targets — sum up over the 7-day plan and aim
|
||||||
|
# for the cumulative budget. Per-recipe macros come from
|
||||||
|
# cauldron_recipe_meta (Sonnet-estimated).
|
||||||
|
targets_block = ""
|
||||||
|
if isinstance(daily_targets, dict) and daily_targets:
|
||||||
|
target_lines = []
|
||||||
|
for k, label in (("calories", "cal"), ("protein_g", "g protein"),
|
||||||
|
("carbs_g", "g carbs"), ("fat_g", "g fat")):
|
||||||
|
v = daily_targets.get(k)
|
||||||
|
try:
|
||||||
|
if v and int(v) > 0:
|
||||||
|
target_lines.append(f"{int(v)}{label}/day ({int(v) * slots}{label}/week)")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if target_lines:
|
||||||
|
targets_block = (
|
||||||
|
"\nDAILY MACRO TARGETS (sum across the 7-day plan):\n - "
|
||||||
|
+ "\n - ".join(target_lines) + "\n\n"
|
||||||
|
"Rule: build the plan so the SUM of per-serving macros across "
|
||||||
|
"all 7 slots lands within ±15% of the weekly totals shown above. "
|
||||||
|
"If recipe macros are null/missing, treat as average (use the "
|
||||||
|
"summary + name to estimate). Trade off slots if needed — push "
|
||||||
|
"high-protein dishes to make protein, push lighter dishes if "
|
||||||
|
"calories are running over. Picks STILL must appear; balance "
|
||||||
|
"with the AI-chosen slots.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Allergen / dietary exclusions — strict filter, not bias
|
||||||
|
excl_block = ""
|
||||||
|
if isinstance(exclusions, list) and exclusions:
|
||||||
|
excl_clean = sorted({
|
||||||
|
str(x).strip().lower() for x in exclusions
|
||||||
|
if isinstance(x, str) and x.strip()
|
||||||
|
})
|
||||||
|
if excl_clean:
|
||||||
|
excl_block = (
|
||||||
|
"\nSTRICT EXCLUSIONS — recipes containing any of these MUST be "
|
||||||
|
f"avoided in AI-chosen slots:\n {', '.join(excl_clean)}\n\n"
|
||||||
|
"Each pool entry shows allergen flags as 'has:dairy,gluten,...'. "
|
||||||
|
"Do NOT pick a recipe whose has: list intersects the exclusions "
|
||||||
|
"above. If a HOUSEHOLD PICK violates the exclusion, include it "
|
||||||
|
"anyway (picks are explicit user choices) but flag the conflict "
|
||||||
|
"in that slot's reason field (e.g., \"contains dairy — "
|
||||||
|
"household pick\").\n"
|
||||||
|
)
|
||||||
|
|
||||||
pref_clean = (preference or "").strip()
|
pref_clean = (preference or "").strip()
|
||||||
pref_block = ""
|
pref_block = ""
|
||||||
if pref_clean:
|
if pref_clean:
|
||||||
|
|
@ -323,6 +373,8 @@ class Forge:
|
||||||
f"{picks_block}\n"
|
f"{picks_block}\n"
|
||||||
f"{profile_block}"
|
f"{profile_block}"
|
||||||
f"{pref_block}"
|
f"{pref_block}"
|
||||||
|
f"{targets_block}"
|
||||||
|
f"{excl_block}"
|
||||||
"Output JSON ONLY, no prose: "
|
"Output JSON ONLY, no prose: "
|
||||||
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
||||||
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
|
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,29 @@ def create_app() -> Flask:
|
||||||
db.enrich_plan_with_slots(plan)
|
db.enrich_plan_with_slots(plan)
|
||||||
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
|
||||||
|
for k in ("daily_targets_json", "exclusions_json"):
|
||||||
|
v = plan.get(k)
|
||||||
|
if isinstance(v, str):
|
||||||
|
try:
|
||||||
|
plan[k] = _json_loads(v)
|
||||||
|
except Exception:
|
||||||
|
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["targets_label"] = ""
|
||||||
|
if targets:
|
||||||
|
bits = []
|
||||||
|
for k, suffix in (("calories", "cal"), ("protein_g", "g pro"),
|
||||||
|
("carbs_g", "g carb"), ("fat_g", "g fat")):
|
||||||
|
v = targets.get(k)
|
||||||
|
if v:
|
||||||
|
bits.append(f"{v}{suffix}")
|
||||||
|
plan["targets_label"] = " · ".join(bits) + "/day" if bits else ""
|
||||||
|
plan["exclusions_label"] = ", ".join(exclusions) if exclusions else ""
|
||||||
|
plan["targets_dict"] = targets or {}
|
||||||
|
plan["exclusions_list"] = exclusions or []
|
||||||
|
|
||||||
# Resolve display names for any subs we render — locked_by + every
|
# Resolve display names for any subs we render — locked_by + every
|
||||||
# picker referenced by any slot. One round-trip; small set.
|
# picker referenced by any slot. One round-trip; small set.
|
||||||
sub_display: dict[str, str] = _resolve_sub_displays(db, plan)
|
sub_display: dict[str, str] = _resolve_sub_displays(db, plan)
|
||||||
|
|
@ -661,13 +684,24 @@ def create_app() -> Flask:
|
||||||
}), 409
|
}), 409
|
||||||
|
|
||||||
# Persist the per-week preference (free-form: "high protein low
|
# Persist the per-week preference (free-form: "high protein low
|
||||||
# carb", "carb load this week", etc.) before kicking off Sonnet
|
# carb", "carb load this week", etc.) + numeric daily targets +
|
||||||
# so a re-roll uses the same preference unless explicitly changed.
|
# allergen exclusions before kicking off Sonnet so a re-roll
|
||||||
|
# uses the same constraints unless explicitly changed.
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
preference = (body.get("preference") or "").strip()
|
preference = (body.get("preference") or "").strip()
|
||||||
if preference:
|
if preference:
|
||||||
db.set_plan_preference(plan["id"], preference)
|
db.set_plan_preference(plan["id"], preference)
|
||||||
plan["preference_prompt"] = preference[:1000]
|
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
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
# Re-fetch so the local plan dict has the persisted values
|
||||||
|
plan = db.get_or_create_plan(hid, this_monday)
|
||||||
|
|
||||||
# Pull picks + recipe pool. The pool splices in:
|
# Pull picks + recipe pool. The pool splices in:
|
||||||
# 1. cauldron_recipe_meta (Sonnet-generated per-recipe attributes)
|
# 1. cauldron_recipe_meta (Sonnet-generated per-recipe attributes)
|
||||||
|
|
@ -733,12 +767,28 @@ 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
|
||||||
|
plan_targets = plan.get("daily_targets_json")
|
||||||
|
if isinstance(plan_targets, str):
|
||||||
|
try:
|
||||||
|
plan_targets = _json_loads(plan_targets)
|
||||||
|
except Exception:
|
||||||
|
plan_targets = None
|
||||||
|
plan_exclusions = plan.get("exclusions_json")
|
||||||
|
if isinstance(plan_exclusions, str):
|
||||||
|
try:
|
||||||
|
plan_exclusions = _json_loads(plan_exclusions)
|
||||||
|
except Exception:
|
||||||
|
plan_exclusions = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slots = forge.generate_plan(
|
slots = forge.generate_plan(
|
||||||
picks=picks, recipes=recipes,
|
picks=picks, recipes=recipes,
|
||||||
slots=7, week_start=this_monday.isoformat(),
|
slots=7, week_start=this_monday.isoformat(),
|
||||||
preference=plan.get("preference_prompt"),
|
preference=plan.get("preference_prompt"),
|
||||||
picker_profiles=picker_profiles,
|
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,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
@ -782,13 +832,23 @@ def create_app() -> Flask:
|
||||||
db.delete_plan_slots(plan["id"])
|
db.delete_plan_slots(plan["id"])
|
||||||
db.clear_plan_generated(plan["id"])
|
db.clear_plan_generated(plan["id"])
|
||||||
|
|
||||||
# Re-roll honors preference: if the body sets one, persist + use.
|
# Re-roll honors preference + targets + exclusions: if the body
|
||||||
# Otherwise reuse the persisted preference from the prior generate.
|
# sets any, persist + use. Otherwise reuse the persisted values
|
||||||
|
# from the prior generate.
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
preference = (body.get("preference") or "").strip()
|
preference = (body.get("preference") or "").strip()
|
||||||
if preference:
|
if preference:
|
||||||
db.set_plan_preference(plan["id"], preference)
|
db.set_plan_preference(plan["id"], preference)
|
||||||
plan["preference_prompt"] = preference[:1000]
|
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
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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
|
||||||
picks = db.list_household_picks_with_pickers(hid)
|
picks = db.list_household_picks_with_pickers(hid)
|
||||||
|
|
@ -815,12 +875,28 @@ 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
|
||||||
|
plan_targets = plan.get("daily_targets_json")
|
||||||
|
if isinstance(plan_targets, str):
|
||||||
|
try:
|
||||||
|
plan_targets = _json_loads(plan_targets)
|
||||||
|
except Exception:
|
||||||
|
plan_targets = None
|
||||||
|
plan_exclusions = plan.get("exclusions_json")
|
||||||
|
if isinstance(plan_exclusions, str):
|
||||||
|
try:
|
||||||
|
plan_exclusions = _json_loads(plan_exclusions)
|
||||||
|
except Exception:
|
||||||
|
plan_exclusions = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
slots = forge.generate_plan(
|
slots = forge.generate_plan(
|
||||||
picks=picks, recipes=recipes,
|
picks=picks, recipes=recipes,
|
||||||
slots=7, week_start=this_monday.isoformat(),
|
slots=7, week_start=this_monday.isoformat(),
|
||||||
preference=plan.get("preference_prompt"),
|
preference=plan.get("preference_prompt"),
|
||||||
picker_profiles=picker_profiles,
|
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,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,73 @@
|
||||||
color: var(--bone);
|
color: var(--bone);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pref-advanced {
|
||||||
|
margin-top: 14px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.pref-advanced summary {
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
color: var(--purple); font-family: var(--mono);
|
||||||
|
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.pref-advanced summary:hover { color: var(--purple-bright); }
|
||||||
|
.pref-advanced-body { margin-top: 10px; }
|
||||||
|
|
||||||
|
.target-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
.target-row { grid-template-columns: 1fr 1fr 1fr 1fr; }
|
||||||
|
}
|
||||||
|
.target-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.target-input {
|
||||||
|
width: 70px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--bone);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--mono); font-size: .9em;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.target-input:focus { outline: none; border-color: var(--purple-bright); }
|
||||||
|
.target-unit {
|
||||||
|
color: var(--muted); font-family: var(--mono);
|
||||||
|
font-size: 11px; letter-spacing: .1em;
|
||||||
|
}
|
||||||
|
.target-presets, .excl-row {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-exclusions { margin-top: 14px; }
|
||||||
|
.excl-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: var(--sans); font-size: .85em;
|
||||||
|
color: var(--bone-dim);
|
||||||
|
cursor: pointer; user-select: none;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
.excl-chip input { margin: 0; cursor: pointer; }
|
||||||
|
.excl-chip:has(input:checked) {
|
||||||
|
background: rgba(232, 96, 106, .12);
|
||||||
|
border-color: rgba(232, 96, 106, .3);
|
||||||
|
color: var(--crit);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
|
|
@ -180,6 +247,12 @@
|
||||||
{% if plan.preference_prompt %}
|
{% if plan.preference_prompt %}
|
||||||
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
|
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if plan.targets_label %}
|
||||||
|
<div class="pref-readout">macros: <em>{{ plan.targets_label }}</em></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan.exclusions_label %}
|
||||||
|
<div class="pref-readout">excluding: <em>{{ plan.exclusions_label }}</em></div>
|
||||||
|
{% 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 claude to build one from the {{ pick_count }} pinned pick{{ '' if pick_count == 1 else 's' }} + the rest of the grimoire.</p>
|
||||||
|
|
||||||
|
|
@ -196,6 +269,56 @@
|
||||||
<button type="button" class="pref-chip" onclick="setPref('recovery week — gentle, easily digestible meals')">💧 recovery</button>
|
<button type="button" class="pref-chip" onclick="setPref('recovery week — gentle, easily digestible meals')">💧 recovery</button>
|
||||||
<button type="button" class="pref-chip" onclick="setPref('global flavors — bias toward varied cuisines this week')">🌍 global</button>
|
<button type="button" class="pref-chip" onclick="setPref('global flavors — bias toward varied cuisines this week')">🌍 global</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details class="pref-advanced">
|
||||||
|
<summary>numeric targets + allergen exclusions (optional)</summary>
|
||||||
|
<div class="pref-advanced-body">
|
||||||
|
<div class="pref-targets">
|
||||||
|
<label class="pref-label">daily macro targets</label>
|
||||||
|
<div class="target-row">
|
||||||
|
<span class="target-cell">
|
||||||
|
<input type="number" id="target-cal" class="target-input" placeholder="2200" min="0" max="10000" step="50" value="{{ plan.targets_dict.calories or '' }}">
|
||||||
|
<span class="target-unit">cal/day</span>
|
||||||
|
</span>
|
||||||
|
<span class="target-cell">
|
||||||
|
<input type="number" id="target-pro" class="target-input" placeholder="150" min="0" max="500" step="5" value="{{ plan.targets_dict.protein_g or '' }}">
|
||||||
|
<span class="target-unit">g protein/day</span>
|
||||||
|
</span>
|
||||||
|
<span class="target-cell">
|
||||||
|
<input type="number" id="target-carb" class="target-input" placeholder="250" min="0" max="800" step="5" value="{{ plan.targets_dict.carbs_g or '' }}">
|
||||||
|
<span class="target-unit">g carbs/day</span>
|
||||||
|
</span>
|
||||||
|
<span class="target-cell">
|
||||||
|
<input type="number" id="target-fat" class="target-input" placeholder="80" min="0" max="300" step="5" value="{{ plan.targets_dict.fat_g or '' }}">
|
||||||
|
<span class="target-unit">g fat/day</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="target-presets">
|
||||||
|
<button type="button" class="pref-chip" onclick="setTargets(2200,150,250,80)">balanced 2200</button>
|
||||||
|
<button type="button" class="pref-chip" onclick="setTargets(2400,200,200,80)">protein lean 2400</button>
|
||||||
|
<button type="button" class="pref-chip" onclick="setTargets(2600,140,350,70)">carb load 2600</button>
|
||||||
|
<button type="button" class="pref-chip" onclick="setTargets(1800,140,140,80)">cut 1800</button>
|
||||||
|
<button type="button" class="pref-chip" onclick="setTargets(0,0,0,0)">clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pref-exclusions">
|
||||||
|
<label class="pref-label">strict exclusions (allergen / dietary)</label>
|
||||||
|
<div class="excl-row">
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="dairy"> dairy</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="gluten"> gluten</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="nuts"> tree nuts</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="peanuts"> peanuts</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="eggs"> eggs</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="shellfish"> shellfish</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="fish"> fish</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="soy"> soy</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="sesame"> sesame</label>
|
||||||
|
<label class="excl-chip"><input type="checkbox" class="excl-check" value="pork"> pork</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</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>
|
||||||
|
|
@ -205,6 +328,12 @@
|
||||||
{% if plan.preference_prompt %}
|
{% if plan.preference_prompt %}
|
||||||
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
|
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if plan.targets_label %}
|
||||||
|
<div class="pref-readout">macros: <em>{{ plan.targets_label }}</em></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if plan.exclusions_label %}
|
||||||
|
<div class="pref-readout">excluding: <em>{{ plan.exclusions_label }}</em></div>
|
||||||
|
{% endif %}
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
|
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
|
||||||
{% if plan.generated_by_sub == current_user_sub %}
|
{% if plan.generated_by_sub == current_user_sub %}
|
||||||
|
|
@ -257,11 +386,50 @@ function setPref(text) {
|
||||||
if (el) { el.value = text; el.focus(); }
|
if (el) { el.value = text; el.focus(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-check exclusion checkboxes from server-rendered persisted state
|
||||||
|
(function hydrateExclusions(){
|
||||||
|
const persisted = {{ plan.exclusions_list | tojson if plan.exclusions_list else '[]' }};
|
||||||
|
if (!Array.isArray(persisted) || !persisted.length) return;
|
||||||
|
const set = new Set(persisted);
|
||||||
|
document.querySelectorAll('.excl-check').forEach(el => {
|
||||||
|
if (set.has(el.value)) el.checked = true;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
function readPref() {
|
function readPref() {
|
||||||
const el = document.getElementById('pref-input');
|
const el = document.getElementById('pref-input');
|
||||||
return el ? el.value.trim() : '';
|
return el ? el.value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTargets(cal, pro, carb, fat) {
|
||||||
|
const map = {'target-cal': cal, 'target-pro': pro, 'target-carb': carb, 'target-fat': fat};
|
||||||
|
for (const id in map) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) continue;
|
||||||
|
el.value = map[id] > 0 ? map[id] : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTargets() {
|
||||||
|
const t = {};
|
||||||
|
for (const [id, key] of [
|
||||||
|
['target-cal','calories'], ['target-pro','protein_g'],
|
||||||
|
['target-carb','carbs_g'], ['target-fat','fat_g']
|
||||||
|
]) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) continue;
|
||||||
|
const n = parseInt(el.value, 10);
|
||||||
|
if (Number.isFinite(n) && n > 0) t[key] = n;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExclusions() {
|
||||||
|
const out = [];
|
||||||
|
document.querySelectorAll('.excl-check:checked').forEach(el => out.push(el.value));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
async function generatePlan(btn) {
|
async function generatePlan(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '🪄 summoning…';
|
btn.innerHTML = '🪄 summoning…';
|
||||||
|
|
@ -271,7 +439,11 @@ async function generatePlan(btn) {
|
||||||
const r = await fetch('/api/plan/generate', {
|
const r = await fetch('/api/plan/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ preference: readPref() }),
|
body: JSON.stringify({
|
||||||
|
preference: readPref(),
|
||||||
|
targets: readTargets(),
|
||||||
|
exclusions: readExclusions(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (r.status === 409) {
|
if (r.status === 409) {
|
||||||
// Someone beat us to it — just reload to see their plan
|
// Someone beat us to it — just reload to see their plan
|
||||||
|
|
@ -291,15 +463,15 @@ async function generatePlan(btn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rerollPlan(btn) {
|
async function rerollPlan(btn) {
|
||||||
// Re-roll keeps the prior preference unless the user opens the
|
// Re-roll reuses persisted preference + targets + exclusions unless
|
||||||
// textarea (only visible pre-generate). For now just reuse persisted.
|
// the user opens those fields (currently only visible pre-generate).
|
||||||
if (!confirm('re-roll the week? slots will be replaced.')) return;
|
if (!confirm('re-roll the week? slots will be replaced.')) return;
|
||||||
btn.disabled = true; btn.textContent = '…';
|
btn.disabled = true; btn.textContent = '…';
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/plan/regenerate', {
|
const r = await fetch('/api/plan/regenerate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({}), // empty body = reuse persisted preference
|
body: JSON.stringify({}), // empty body = reuse persisted constraints
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue