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
|
||||
) 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),
|
||||
)
|
||||
|
||||
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:
|
||||
"""Lock a plan if not already locked. Returns updated plan dict."""
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ class Forge:
|
|||
week_start: str,
|
||||
preference: str | None = None,
|
||||
picker_profiles: dict | None = None,
|
||||
daily_targets: dict | None = None,
|
||||
exclusions: list | None = None,
|
||||
model: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
||||
|
|
@ -112,6 +114,7 @@ class Forge:
|
|||
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,
|
||||
)
|
||||
result = self.run(prompt, model=model or "sonnet")
|
||||
parsed = _extract_plan_slots(result)
|
||||
|
|
@ -165,7 +168,8 @@ class Forge:
|
|||
|
||||
@staticmethod
|
||||
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 = []
|
||||
for r in recipes:
|
||||
slug = r.get("slug") or ""
|
||||
|
|
@ -299,6 +303,52 @@ class Forge:
|
|||
"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_block = ""
|
||||
if pref_clean:
|
||||
|
|
@ -323,6 +373,8 @@ class Forge:
|
|||
f"{picks_block}\n"
|
||||
f"{profile_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'
|
||||
|
|
|
|||
|
|
@ -592,6 +592,29 @@ def create_app() -> Flask:
|
|||
db.enrich_plan_with_slots(plan)
|
||||
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
|
||||
# picker referenced by any slot. One round-trip; small set.
|
||||
sub_display: dict[str, str] = _resolve_sub_displays(db, plan)
|
||||
|
|
@ -661,13 +684,24 @@ def create_app() -> Flask:
|
|||
}), 409
|
||||
|
||||
# Persist the per-week preference (free-form: "high protein low
|
||||
# carb", "carb load this week", etc.) before kicking off Sonnet
|
||||
# so a re-roll uses the same preference unless explicitly changed.
|
||||
# carb", "carb load this week", etc.) + numeric daily targets +
|
||||
# allergen exclusions before kicking off Sonnet so a re-roll
|
||||
# uses the same constraints unless explicitly changed.
|
||||
body = request.get_json(silent=True) or {}
|
||||
preference = (body.get("preference") or "").strip()
|
||||
if preference:
|
||||
db.set_plan_preference(plan["id"], preference)
|
||||
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:
|
||||
# 1. cauldron_recipe_meta (Sonnet-generated per-recipe attributes)
|
||||
|
|
@ -733,12 +767,28 @@ 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
|
||||
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:
|
||||
slots = forge.generate_plan(
|
||||
picks=picks, recipes=recipes,
|
||||
slots=7, week_start=this_monday.isoformat(),
|
||||
preference=plan.get("preference_prompt"),
|
||||
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:
|
||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||
|
|
@ -782,13 +832,23 @@ def create_app() -> Flask:
|
|||
db.delete_plan_slots(plan["id"])
|
||||
db.clear_plan_generated(plan["id"])
|
||||
|
||||
# Re-roll honors preference: if the body sets one, persist + use.
|
||||
# Otherwise reuse the persisted preference from the prior generate.
|
||||
# Re-roll honors preference + targets + exclusions: if the body
|
||||
# sets any, persist + use. Otherwise reuse the persisted values
|
||||
# from the prior generate.
|
||||
body = request.get_json(silent=True) or {}
|
||||
preference = (body.get("preference") or "").strip()
|
||||
if preference:
|
||||
db.set_plan_preference(plan["id"], preference)
|
||||
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
|
||||
picks = db.list_household_picks_with_pickers(hid)
|
||||
|
|
@ -815,12 +875,28 @@ 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
|
||||
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:
|
||||
slots = forge.generate_plan(
|
||||
picks=picks, recipes=recipes,
|
||||
slots=7, week_start=this_monday.isoformat(),
|
||||
preference=plan.get("preference_prompt"),
|
||||
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:
|
||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||
|
|
|
|||
|
|
@ -139,6 +139,73 @@
|
|||
color: var(--bone);
|
||||
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>
|
||||
|
||||
<div class="page-head">
|
||||
|
|
@ -180,6 +247,12 @@
|
|||
{% if plan.preference_prompt %}
|
||||
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
|
||||
{% 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 %}
|
||||
<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('global flavors — bias toward varied cuisines this week')">🌍 global</button>
|
||||
</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>
|
||||
|
||||
<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 %}
|
||||
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
|
||||
{% 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">
|
||||
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
|
||||
{% if plan.generated_by_sub == current_user_sub %}
|
||||
|
|
@ -257,11 +386,50 @@ function setPref(text) {
|
|||
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() {
|
||||
const el = document.getElementById('pref-input');
|
||||
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) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '🪄 summoning…';
|
||||
|
|
@ -271,7 +439,11 @@ async function generatePlan(btn) {
|
|||
const r = await fetch('/api/plan/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ preference: readPref() }),
|
||||
body: JSON.stringify({
|
||||
preference: readPref(),
|
||||
targets: readTargets(),
|
||||
exclusions: readExclusions(),
|
||||
}),
|
||||
});
|
||||
if (r.status === 409) {
|
||||
// Someone beat us to it — just reload to see their plan
|
||||
|
|
@ -291,15 +463,15 @@ async function generatePlan(btn) {
|
|||
}
|
||||
|
||||
async function rerollPlan(btn) {
|
||||
// Re-roll keeps the prior preference unless the user opens the
|
||||
// textarea (only visible pre-generate). For now just reuse persisted.
|
||||
// Re-roll reuses persisted preference + targets + exclusions unless
|
||||
// the user opens those fields (currently only visible pre-generate).
|
||||
if (!confirm('re-roll the week? slots will be replaced.')) return;
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
try {
|
||||
const r = await fetch('/api/plan/regenerate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}), // empty body = reuse persisted preference
|
||||
body: JSON.stringify({}), // empty body = reuse persisted constraints
|
||||
});
|
||||
if (!r.ok) {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue