cauldron/cauldron/templates/list.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

229 lines
7.6 KiB
HTML

{% extends "_base.html" %}
{% block title %}Shopping List · Cauldron{% endblock %}
{% block content %}
<style>
/* Shopping list layout. Mobile-first one-thumb tap targets — every
check row is 48px tall. Sticky bottom bar on phones. */
.list-bar {
position: sticky; bottom: 0; left: 0; right: 0; z-index: 40;
margin: 24px -22px -80px -22px;
padding: 14px 22px calc(14px + env(safe-area-inset-bottom)) 22px;
background: rgba(10, 10, 12, .92);
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
border-top: 1px solid var(--line);
display: flex; gap: 10px; flex-wrap: wrap;
}
.list-bar .btn { flex: 1; min-width: 100px; min-height: 48px; line-height: 1.2; }
.group-head {
color: var(--purple); font-family: var(--mono);
font-size: 11px; letter-spacing: .2em; text-transform: uppercase;
margin: 22px 0 8px 0; padding-bottom: 6px;
border-bottom: 1px solid var(--line-soft);
}
.group-head:first-of-type { margin-top: 4px; }
ul.shop-list { list-style: none; padding: 0; margin: 0; }
ul.shop-list li {
display: flex; align-items: flex-start; gap: 14px;
min-height: 56px; padding: 10px 4px;
border-bottom: 1px solid var(--line-soft);
cursor: pointer; -webkit-tap-highlight-color: transparent;
transition: background .12s, opacity .15s;
}
ul.shop-list li:active { background: var(--surface-2); }
ul.shop-list li.checked .qty,
ul.shop-list li.checked .food { text-decoration: line-through; opacity: .55; }
ul.shop-list li.checked .meta { opacity: .4; }
.check-box {
flex-shrink: 0;
width: 28px; height: 28px;
margin-top: 4px;
border: 2px solid var(--line);
border-radius: 5px;
background: var(--bg-2);
display: flex; align-items: center; justify-content: center;
color: var(--green-bright); font-size: 18px; line-height: 1;
transition: all .15s;
}
ul.shop-list li.checked .check-box {
border-color: var(--green-dim);
background: rgba(110, 168, 72, .15);
}
.check-box::before {
content: ""; opacity: 0; font-weight: bold;
}
ul.shop-list li.checked .check-box::before {
content: "✓"; opacity: 1;
}
.row-main { flex: 1; min-width: 0; }
.row-main .qty {
color: var(--green-bright); font-family: var(--mono);
font-weight: 600; font-size: 1.05em;
margin-right: .5em;
}
.row-main .food {
color: var(--bone); font-family: var(--serif); font-weight: 500;
font-size: 1.1em; letter-spacing: .02em;
}
.row-main .meta {
color: var(--muted); font-family: var(--mono);
font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
margin-top: 4px;
}
.row-main .notes {
color: var(--bone-dim); font-style: italic;
font-size: .9em; margin-top: 3px;
}
.empty-cta {
text-align: center; padding: 24px 16px;
}
.empty-cta .icon { font-size: 2.4em; opacity: .5; }
.empty-cta a { font-family: var(--serif); font-size: 1.1em; letter-spacing: .05em; }
.warn-box {
border-left: 2px solid var(--warn);
background: rgba(212, 168, 84, .06);
padding: 10px 14px; margin: 12px 0;
color: var(--warn); font-family: var(--mono);
font-size: 11px; letter-spacing: .1em; text-transform: uppercase;
border-radius: 0 4px 4px 0;
}
/* On phones, give the list room above the sticky bar */
@media (max-width: 720px) {
main { padding-bottom: 120px; }
}
</style>
<div class="page-head">
<div class="crumb">// list · week of {{ plan.week_start.strftime('%b %-d') }}</div>
<h1>shopping <span class="accent">list</span></h1>
<div class="lede">
{% if empty_reason == 'no_plan' %}
no plan yet — head over to /plan and summon one first.
{% elif lines %}
{{ lines|length }} line{{ '' if lines|length == 1 else 's' }} · {{ plan.slots|length }} recipe{{ '' if plan.slots|length == 1 else 's' }} · check off as you shop, state survives refresh.
{% else %}
plan is set but no aggregatable ingredients came back.
{% endif %}
</div>
</div>
{% if empty_reason == 'no_plan' %}
<section class="panel green">
<div class="panel-head">
<h2>no plan yet</h2>
</div>
<div class="empty-cta">
<div class="icon">🪄</div>
<p style="margin: 1em 0;">summon this week's plan first — every recipe's ingredients flow here automatically, density-aware aggregated.</p>
<a class="btn btn-primary" href="/plan">go to /plan →</a>
</div>
</section>
{% else %}
{% if missing_recipes %}
<div class="warn-box">
⚠ {{ missing_recipes|length }} recipe{{ '' if missing_recipes|length == 1 else 's' }} couldn't be loaded from mealie:
{% for slug in missing_recipes %}<code>{{ slug }}</code>{% if not loop.last %}, {% endif %}{% endfor %}.
the list below is partial — check /plan and re-roll if needed.
</div>
{% endif %}
<section class="panel purple">
<div class="panel-head">
<h2>the list</h2>
<span class="ctx">{{ lines|length }} item{{ '' if lines|length == 1 else 's' }}</span>
</div>
{% if lines %}
{# Flat list for now — aggregator output doesn't carry category. v0.4 may
re-introduce per-category sections via foods.category once dedupe lands. #}
<ul class="shop-list" id="shop-list" data-plan-id="{{ plan.id }}">
{% for ln in lines %}
<li class="row" data-key="{{ loop.index0 }}">
<div class="check-box" aria-hidden="true"></div>
<div class="row-main">
<div>
{% if ln.qty is not none %}<span class="qty">{{ ln.qty }} {{ ln.unit }}</span>{% endif %}
<span class="food">{{ ln.food }}</span>
{% if ln.is_split %}<span class="muted" style="margin-left:.5em;font-size:.85em;">(split)</span>{% endif %}
</div>
{% if ln.contributors %}
<div class="meta">from {{ ln.contributors|length }} recipe{{ '' if ln.contributors|length == 1 else 's' }}</div>
{% endif %}
{% if ln.notes %}
<div class="notes">{{ ln.notes|join(', ') }}</div>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">no aggregatable ingredients. check /plan.</p>
{% endif %}
</section>
<div class="list-bar">
<button class="btn" type="button" id="check-all">mark all done</button>
<button class="btn" type="button" id="clear-all">clear</button>
<button class="btn btn-purple" type="button" disabled title="coming in v0.4">send to mealie ↗</button>
</div>
<script>
(function(){
const list = document.getElementById('shop-list');
if (!list) return;
const planId = list.dataset.planId;
const STORE_KEY = 'cauldron-list-checked-' + planId;
function load() {
try { return new Set(JSON.parse(localStorage.getItem(STORE_KEY) || '[]')); }
catch (e) { return new Set(); }
}
function save(set) {
try { localStorage.setItem(STORE_KEY, JSON.stringify([...set])); }
catch (e) {}
}
let checked = load();
for (const li of list.querySelectorAll('li.row')) {
if (checked.has(li.dataset.key)) li.classList.add('checked');
}
list.addEventListener('click', (e) => {
const li = e.target.closest('li.row');
if (!li) return;
li.classList.toggle('checked');
const k = li.dataset.key;
if (li.classList.contains('checked')) checked.add(k);
else checked.delete(k);
save(checked);
});
document.getElementById('check-all').addEventListener('click', () => {
for (const li of list.querySelectorAll('li.row')) {
li.classList.add('checked');
checked.add(li.dataset.key);
}
save(checked);
});
document.getElementById('clear-all').addEventListener('click', () => {
for (const li of list.querySelectorAll('li.row')) {
li.classList.remove('checked');
}
checked.clear();
save(checked);
});
})();
</script>
{% endif %}
{% endblock %}