cauldron/cauldron/templates/plan.html
Kayos a88a60e181 plan: reset button + week navigation + historical browsing
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.
2026-04-30 20:44:08 -07:00

572 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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 &amp; 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 %}