cauldron/cauldron/templates/plan.html
Kayos 36aba73f66 v0.3 step 3+4: AI plan generator + /list shopping aggregation
- migrations 012 + 013: cauldron_meal_plan_slots + cauldron_pick_points
- db: list_plan_slots, save_plan_slots, delete_plan_slots, mark_plan_generated,
  clear_plan_generated, award_pick_points, enrich_plan_with_slots; scoreboard
  extended with points (sum from pick_points) and weeks_locked alias
- forge.generate_plan: sonnet prompt builds 7-day plan respecting picks,
  validates slot count + day uniqueness + slug-in-pool, fills picker_subs
  from ground-truth picks (model output is advisory)
- POST /api/plan/generate: race-safe (existing slots → 409 with plan),
  lock-aware (locked → 409), idempotent
- POST /api/plan/regenerate: re-roll for the original generator, gated by
  ownership + lock; wipes slots + pick_points then re-runs generate
- plan.html: generate CTA + 7 day cards with picker chips + AI reason +
  re-roll button (generator-only, pre-lock); scoreboard now shows points + wins
- /list: pulls plan slots, queries Mealie for ingredients, runs aggregator,
  renders 48px-tall checkbox shopping list with localStorage state per plan_id
- tests: 13 new tests across forge.generate_plan + /api/plan/generate routes
  + /list view + scoreboard SQL inspection. conftest+_testenv stub
  pymysql/oidc/foods at import time so tests run against module-level app
  without a live DB. Both pytest and `unittest discover` paths green (27/27).

Defers: bulk sterilizer admin (A1), foods dedupe (A2), Mealie shopping-list-
export (button rendered but disabled). 7-slot count is fixed at the
endpoint (no UI for slot-count selection yet).

Spec: memory/spec-cauldron-v0.3.md
2026-04-29 06:26:54 -07:00

265 lines
10 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;
}
</style>
<div class="page-head">
<div class="crumb">// plan · week of {{ week_start.strftime('%b %-d') }}</div>
<h1>this <span class="accent">week</span></h1>
<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 when ready — first to lock takes the week.
{% else %}
{{ pick_count }} pinned in the pool. summon the planner to build a 7-day plan, then race to lock.
{% 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.locked_reason == 'user' and plan.locked_by_sub == current_user_sub %}
<p class="muted">🏆 you locked this one in.</p>
{% 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>
<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. lock it in to claim the week — first to lock wins.</p>
<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 %}
<a class="btn" href="/list">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 %}
<section class="panel">
<div class="panel-head">
<h2>scoreboard</h2>
<span class="ctx">picks landed · weeks won</span>
</div>
{% if streak and streak.count >= 2 %}
<p style="font-size: 1.05em; margin: .2em 0 1em 0;">
🔥 <strong style="color: var(--green-bright);">{{ streak.display_name }}</strong>
on a <strong>{{ streak.count }}-week</strong> run.
</p>
{% endif %}
{% if scoreboard and scoreboard|selectattr("points")|list or scoreboard and scoreboard|selectattr("wins")|list %}
<table style="width: 100%; border-collapse: collapse; font-size: .95em;">
<thead>
<tr style="border-bottom: 1px solid var(--line);">
<th style="text-align: left; color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); font-weight: 600; padding: 6px 0;">member</th>
<th style="text-align: right; color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); font-weight: 600; padding: 6px 0;">pts</th>
<th style="text-align: right; color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); font-weight: 600; padding: 6px 0;">wins</th>
<th style="text-align: right; color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); font-weight: 600; padding: 6px 0;">last</th>
</tr>
</thead>
<tbody>
{% for s in scoreboard %}
<tr style="border-bottom: 1px solid var(--line-soft);">
<td style="padding: 8px 0;">
{{ loop.index }}.
<strong style="color: {% if loop.first and (s.points or s.wins) %}var(--green-bright){% else %}var(--bone){% endif %};">
{{ s.display_name or s.email.split('@')[0] }}
</strong>
{% if s.sub == current_user_sub %}<span class="muted">· you</span>{% endif %}
</td>
<td style="text-align: right; padding: 8px 0; font-family: var(--mono); color: var(--bone);">{{ s.points or 0 }}</td>
<td style="text-align: right; padding: 8px 0; font-family: var(--mono); color: var(--bone);">{{ s.wins or 0 }}</td>
<td style="text-align: right; padding: 8px 0; font-family: var(--mono); color: var(--muted); font-size: .85em;">
{{ s.last_win.strftime('%b %-d') if s.last_win else '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">nobody's locked or generated yet. be the first.</p>
{% endif %}
</section>
<script>
async function lockPlan(btn) {
btn.disabled = true; btn.textContent = '…';
try {
const r = await fetch('/api/plan/lock', { method: 'POST' });
if (!r.ok) throw new Error(r.status);
location.reload();
} catch (e) {
btn.disabled = false; btn.innerHTML = '🔒 lock this week';
}
}
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' });
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) {
if (!confirm('re-roll the week? slots + points reset.')) return;
btn.disabled = true; btn.textContent = '…';
try {
const r = await fetch('/api/plan/regenerate', { method: 'POST' });
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 %}