- 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
229 lines
7.6 KiB
HTML
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 %}
|