- 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
265 lines
10 KiB
HTML
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 %}
|