Three connected features so the planner is a real tool, not just a
single-week dashboard:
1. RESET. New POST /api/plan/reset wipes a plan back to blank — clears
slots + generated_at + preference_prompt + daily_targets_json +
exclusions_json. Hard-guards on lock state: locked plans return 409
"locked weeks are immutable history". This is the historical-
preservation guarantee — once a week is locked (via /api/plan/lock
OR auto-lock at week-rollover), it can never be touched again.
That's how 'what did we eat May 15 six years ago' stays answerable
forever.
2. WEEK NAVIGATION. /plan now accepts ?week=YYYY-MM-DD (snapped to
that week's Monday). Defaults to the current week as before. New
page-head nav: ← prev week / ⊕ this week (only when off-current) /
next week → / week-of-X span label. /list also accepts ?week= so
the shopping list view follows the same pattern.
3. HISTORICAL BROWSING. Past weeks render their plan slots as before —
just locked + immutable. Their preference_prompt + macros +
exclusions render in the readouts so you can see WHY that week
looked the way it did. The data was already preserved; this just
surfaces it through the existing /plan UI.
API + template changes:
- _resolve_week helper picks target Monday from body['week'] or today
- /api/plan/{lock,generate,regenerate,reset} all accept body['week']
- Plan view passes prev_week/next_week/current_week + is_*_week flags
+ week_end (Monday + 6) for the date-range label
- Template: PLAN_WEEK js constant threads the active week into every
mutation API call so prev/next nav can act on the displayed week,
not always today's
- Reset button styled red ('btn-danger'), only shown on unlocked
generated plans, confirms before firing
- 'view list →' link now passes ?week= so it stays in-week
DB:
- reset_plan(plan_id) wipes UNLOCKED plan state in one transaction.
Returns False (no-op) if the plan is locked — caller sees 409.
No schema changes — just leverages the per-week (household, week_start)
row uniqueness we already had.
572 lines
21 KiB
HTML
572 lines
21 KiB
HTML
{% extends "_base.html" %}
|
||
{% block title %}This Week · Cauldron{% endblock %}
|
||
{% block content %}
|
||
|
||
<style>
|
||
/* Day-card grid + slot styling — kept here so plan-only CSS doesn't bloat _base. */
|
||
.day-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
@media (min-width: 720px) {
|
||
.day-grid { grid-template-columns: 1fr 1fr; gap: 14px; }
|
||
}
|
||
.day-card {
|
||
background: var(--bg-2);
|
||
border: 1px solid var(--line);
|
||
border-left: 3px solid var(--green-dim);
|
||
border-radius: 6px;
|
||
padding: 14px 16px;
|
||
min-height: 88px;
|
||
display: flex; flex-direction: column; gap: 8px;
|
||
transition: border-color .15s, background .15s;
|
||
}
|
||
.day-card.from-pick {
|
||
border-left-color: var(--purple-bright);
|
||
background: rgba(45, 29, 74, .25);
|
||
}
|
||
.day-card .dlabel {
|
||
color: var(--purple); font-family: var(--mono);
|
||
font-size: 11px; letter-spacing: .2em; text-transform: uppercase;
|
||
}
|
||
.day-card .rname {
|
||
color: var(--bone); font-family: var(--serif);
|
||
font-size: 1.1em; line-height: 1.25; letter-spacing: .02em;
|
||
text-decoration: none;
|
||
}
|
||
.day-card .rname:hover { color: var(--purple-bright); }
|
||
.day-card .pickers { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.day-card .pchip {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
background: var(--purple-deep); color: var(--purple-bright);
|
||
border: 1px solid var(--purple-dim);
|
||
padding: 2px 8px; border-radius: 999px;
|
||
font-family: var(--mono); font-size: 10px; letter-spacing: .1em;
|
||
text-transform: uppercase;
|
||
}
|
||
.day-card .reason {
|
||
color: var(--bone-dim); font-size: .9em; font-style: italic;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.gen-row {
|
||
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
|
||
margin-top: 12px;
|
||
}
|
||
.gen-cta {
|
||
display: block; width: 100%; padding: 1.1em 1.4em;
|
||
background: var(--purple-deep); color: var(--bone);
|
||
border: 1px solid var(--purple-dim);
|
||
border-radius: 8px;
|
||
font-family: var(--serif); font-weight: 600;
|
||
font-size: 1.15em; letter-spacing: .1em; text-transform: uppercase;
|
||
cursor: pointer; transition: all .2s ease;
|
||
box-shadow: 0 0 24px -8px var(--purple-glow);
|
||
min-height: 56px;
|
||
}
|
||
.gen-cta:hover {
|
||
background: var(--purple-dim); border-color: var(--purple-bright);
|
||
box-shadow: 0 0 32px -4px var(--purple-glow);
|
||
}
|
||
.gen-cta[disabled] { opacity: .55; cursor: wait; }
|
||
|
||
.gen-meta {
|
||
color: var(--muted); font-family: var(--mono);
|
||
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.pref-block {
|
||
margin: 14px 0;
|
||
padding: 14px;
|
||
background: var(--bg-2);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
}
|
||
.pref-label {
|
||
display: block;
|
||
color: var(--purple); font-family: var(--mono);
|
||
font-size: 11px; letter-spacing: .2em; text-transform: uppercase;
|
||
margin-bottom: 6px;
|
||
}
|
||
.pref-input {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
background: var(--surface);
|
||
color: var(--bone);
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
font-family: var(--sans); font-size: .95em;
|
||
line-height: 1.4;
|
||
resize: vertical;
|
||
min-height: 56px;
|
||
}
|
||
.pref-input:focus { outline: none; border-color: var(--purple-bright); }
|
||
.pref-presets {
|
||
display: flex; flex-wrap: wrap; gap: 6px;
|
||
margin-top: 10px;
|
||
}
|
||
.pref-chip {
|
||
padding: 6px 12px;
|
||
background: var(--bg);
|
||
color: var(--bone-dim);
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
font-family: var(--sans); font-size: .85em;
|
||
cursor: pointer;
|
||
min-height: 32px;
|
||
transition: all .12s ease;
|
||
}
|
||
.pref-chip:hover {
|
||
background: var(--purple-deep);
|
||
border-color: var(--purple-dim);
|
||
color: var(--bone);
|
||
}
|
||
.pref-chip:active { transform: scale(.97); }
|
||
|
||
.week-nav {
|
||
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||
margin: 10px 0 4px;
|
||
}
|
||
.week-btn {
|
||
padding: 6px 12px;
|
||
background: var(--bg-2);
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
color: var(--bone-dim);
|
||
font-family: var(--mono); font-size: .85em;
|
||
text-decoration: none;
|
||
min-height: 32px;
|
||
display: inline-flex; align-items: center;
|
||
}
|
||
.week-btn:hover {
|
||
background: var(--purple-deep); border-color: var(--purple-dim); color: var(--bone);
|
||
}
|
||
.week-btn.week-now {
|
||
border-color: var(--purple-dim);
|
||
color: var(--purple-bright);
|
||
}
|
||
.week-label {
|
||
color: var(--muted); font-family: var(--mono);
|
||
font-size: .8em; letter-spacing: .1em;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.btn-danger {
|
||
background: rgba(232,96,106,.1); color: var(--crit);
|
||
border-color: rgba(232,96,106,.4);
|
||
}
|
||
.btn-danger:hover {
|
||
background: rgba(232,96,106,.2); border-color: var(--crit);
|
||
}
|
||
|
||
.pref-readout {
|
||
color: var(--bone-dim);
|
||
font-family: var(--serif);
|
||
font-size: .95em;
|
||
margin: 8px 0;
|
||
padding: 8px 12px;
|
||
border-left: 2px solid var(--purple-dim);
|
||
background: rgba(45, 29, 74, .15);
|
||
}
|
||
.pref-readout em {
|
||
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">
|
||
<div class="crumb">// plan · week of {{ week_start.strftime('%b %-d, %Y') }}</div>
|
||
<h1>
|
||
{% if is_current_week %}this <span class="accent">week</span>
|
||
{% elif is_past_week %}<span class="accent">past</span> week
|
||
{% else %}<span class="accent">future</span> week
|
||
{% endif %}
|
||
</h1>
|
||
|
||
<nav class="week-nav">
|
||
<a class="week-btn" href="/plan?week={{ prev_week }}">← prev week</a>
|
||
{% if not is_current_week %}
|
||
<a class="week-btn week-now" href="/plan?week={{ current_week }}">⊕ this week</a>
|
||
{% endif %}
|
||
<a class="week-btn" href="/plan?week={{ next_week }}">next week →</a>
|
||
<span class="week-label">{{ week_start.strftime('%a %b %-d') }} – {{ week_end.strftime('%a %b %-d') }}</span>
|
||
</nav>
|
||
|
||
<div class="lede">
|
||
{% if plan.locked_at %}
|
||
locked
|
||
{% if plan.locked_reason == 'user' and locked_by_display %}
|
||
by <span style="color: var(--green-bright);">{{ locked_by_display }}</span>
|
||
{% else %}
|
||
automatically
|
||
{% endif %}
|
||
· {{ plan.locked_at.strftime('%a %-I:%M %p') }}
|
||
{% elif plan.slots %}
|
||
generated{% if generated_by_display %} by <span style="color: var(--green-bright);">{{ generated_by_display }}</span>{% endif %}.
|
||
lock it in when the household has agreed.
|
||
{% else %}
|
||
{{ pick_count }} pinned in the pool. summon the planner to build a 7-day plan.
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<section class="panel {% if plan.locked_at %}purple{% else %}green{% endif %}">
|
||
<div class="panel-head">
|
||
<h2>state</h2>
|
||
{% if plan.locked_at %}
|
||
<span class="pill pill-mute">{{ 'auto-locked' if plan.locked_reason == 'auto' else 'locked' }}</span>
|
||
{% elif plan.slots %}
|
||
<span class="pill pill-ok">generated</span>
|
||
{% else %}
|
||
<span class="pill pill-ok">open</span>
|
||
{% endif %}
|
||
<span class="ctx">week of {{ week_start.strftime('%b %-d, %Y') }}</span>
|
||
</div>
|
||
|
||
{% if plan.locked_at %}
|
||
<p>this week's plan is locked. it'll archive Sunday night and a fresh week opens Monday.</p>
|
||
{% 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>
|
||
|
||
<div class="pref-block">
|
||
<label class="pref-label" for="pref-input">this week's vibe (optional)</label>
|
||
<textarea id="pref-input" class="pref-input" rows="2" maxlength="1000" placeholder="e.g. high protein low carb · carb load · light recovery week · no fish · vegetarian-leaning · spicy comfort food">{{ plan.preference_prompt or '' }}</textarea>
|
||
<div class="pref-presets">
|
||
<button type="button" class="pref-chip" onclick="setPref('high protein, low carb — lean and gym-friendly')">🥩 high protein</button>
|
||
<button type="button" class="pref-chip" onclick="setPref('carb load this week, training hard')">🍞 carb load</button>
|
||
<button type="button" class="pref-chip" onclick="setPref('light and lean, simple weeknight meals')">🥗 light & lean</button>
|
||
<button type="button" class="pref-chip" onclick="setPref('vegetarian-leaning, more produce-forward')">🌱 vegetarian</button>
|
||
<button type="button" class="pref-chip" onclick="setPref('comfort food week, hearty and warming')">🍲 comfort food</button>
|
||
<button type="button" class="pref-chip" onclick="setPref('quick and easy — 30 minutes or less per meal')">⚡ quick</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>
|
||
</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>
|
||
<div class="gen-meta" id="gen-meta">sonnet · ~30s</div>
|
||
{% else %}
|
||
<p>plan's set.</p>
|
||
{% 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 %}
|
||
<button class="btn" type="button" onclick="rerollPlan(this)" id="reroll-btn">↻ re-roll</button>
|
||
{% endif %}
|
||
<button class="btn btn-danger" type="button" onclick="resetPlan(this)" id="reset-btn">⌫ reset week</button>
|
||
<a class="btn" href="/list?week={{ week_start.isoformat() }}">view list →</a>
|
||
</div>
|
||
{% endif %}
|
||
</section>
|
||
|
||
{% if plan.slots %}
|
||
<section class="panel purple">
|
||
<div class="panel-head">
|
||
<h2>the week</h2>
|
||
<span class="ctx">{{ plan.slots|length }} day{{ '' if plan.slots|length == 1 else 's' }}</span>
|
||
</div>
|
||
<div class="day-grid">
|
||
{% for s in plan.slots %}
|
||
<a class="day-card recipe-card {% if s.picker_subs %}from-pick{% endif %}" href="/recipes/{{ s.recipe_slug }}" data-slug="{{ s.recipe_slug }}" data-name="{{ s.recipe_name }}">
|
||
<div class="dlabel">{{ s.day }}</div>
|
||
<div class="rname">{{ s.recipe_name }}</div>
|
||
{% if s.picker_subs %}
|
||
<div class="pickers">
|
||
{% for sub in s.picker_subs %}
|
||
<span class="pchip">🍄 {{ sub_display.get(sub, 'family') }}</span>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% if s.reason %}<div class="reason">{{ s.reason }}</div>{% endif %}
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
</section>
|
||
{% endif %}
|
||
|
||
<script>
|
||
// The week we're operating on — needed so prev/next nav can act on
|
||
// non-current weeks without each endpoint having to re-derive from URL.
|
||
const PLAN_WEEK = "{{ week_start.isoformat() }}";
|
||
|
||
async function lockPlan(btn) {
|
||
btn.disabled = true; btn.textContent = '…';
|
||
try {
|
||
const r = await fetch('/api/plan/lock', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ week: PLAN_WEEK }),
|
||
});
|
||
if (!r.ok) throw new Error(r.status);
|
||
location.reload();
|
||
} catch (e) {
|
||
btn.disabled = false; btn.innerHTML = '🔒 lock this week';
|
||
}
|
||
}
|
||
|
||
async function resetPlan(btn) {
|
||
if (!confirm('reset this week back to blank? slots, vibe, macros, exclusions all wiped. (locked weeks cannot be reset — that\'s your historical record.)')) return;
|
||
btn.disabled = true; btn.textContent = '…';
|
||
try {
|
||
const r = await fetch('/api/plan/reset', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ week: PLAN_WEEK }),
|
||
});
|
||
if (!r.ok) {
|
||
const data = await r.json().catch(() => ({}));
|
||
throw new Error(data.detail || data.error || r.status);
|
||
}
|
||
location.reload();
|
||
} catch (e) {
|
||
btn.disabled = false; btn.textContent = '⌫ reset week';
|
||
alert('reset failed: ' + e.message);
|
||
}
|
||
}
|
||
|
||
function setPref(text) {
|
||
const el = document.getElementById('pref-input');
|
||
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…';
|
||
const meta = document.getElementById('gen-meta');
|
||
if (meta) meta.textContent = 'sonnet building plan — hold tight';
|
||
try {
|
||
const r = await fetch('/api/plan/generate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
week: PLAN_WEEK,
|
||
preference: readPref(),
|
||
targets: readTargets(),
|
||
exclusions: readExclusions(),
|
||
}),
|
||
});
|
||
if (r.status === 409) {
|
||
// Someone beat us to it — just reload to see their plan
|
||
location.reload();
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
const data = await r.json().catch(() => ({}));
|
||
throw new Error(data.detail || data.error || r.status);
|
||
}
|
||
location.reload();
|
||
} catch (e) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '🪄 generate this week\'s plan';
|
||
if (meta) meta.textContent = 'failed: ' + e.message;
|
||
}
|
||
}
|
||
|
||
async function rerollPlan(btn) {
|
||
// 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({ week: PLAN_WEEK }), // reuse persisted constraints for this week
|
||
});
|
||
if (!r.ok) {
|
||
const data = await r.json().catch(() => ({}));
|
||
throw new Error(data.detail || data.error || r.status);
|
||
}
|
||
location.reload();
|
||
} catch (e) {
|
||
btn.disabled = false; btn.textContent = '↻ re-roll';
|
||
alert('re-roll failed: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// Day cards have class .recipe-card → the base modal handler picks them up
|
||
// automatically (single-click → modal, ctrl/cmd-click → new tab).
|
||
</script>
|
||
|
||
{% endblock %}
|