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.
This commit is contained in:
Kayos 2026-04-30 20:44:08 -07:00
parent 07dab10c4b
commit a88a60e181
3 changed files with 200 additions and 21 deletions

View file

@ -723,6 +723,29 @@ class DB:
), ),
) )
def reset_plan(self, plan_id: int) -> bool:
"""Wipe an UNLOCKED plan back to blank slate: delete slots, clear
generated_at + generated_by_sub, clear preference_prompt +
daily_targets_json + exclusions_json. Returns True if the row was
eligible (unlocked) and reset; False if locked (untouched
locked plans are immutable history)."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""UPDATE cauldron_meal_plans
SET generated_at=NULL, generated_by_sub=NULL,
preference_prompt=NULL, daily_targets_json=NULL,
exclusions_json=NULL
WHERE id=%s AND locked_at IS NULL""",
(plan_id,),
)
if cur.rowcount == 0:
return False
cur.execute(
"DELETE FROM cauldron_meal_plan_slots WHERE plan_id=%s",
(plan_id,),
)
return True
def lock_plan(self, plan_id: int, *, sub: str, reason: str = "user") -> dict: def lock_plan(self, plan_id: int, *, sub: str, reason: str = "user") -> dict:
"""Lock a plan if not already locked. Returns updated plan dict.""" """Lock a plan if not already locked. Returns updated plan dict."""
with self.conn() as c, c.cursor() as cur: with self.conn() as c, c.cursor() as cur:

View file

@ -584,11 +584,35 @@ def create_app() -> Flask:
return redirect(url_for("connect_mealie_get")) return redirect(url_for("connect_mealie_get"))
today = date.today() today = date.today()
this_monday = monday_of(today) current_monday = monday_of(today)
# Auto-lock any past unlocked weeks before reading # Auto-lock any past unlocked weeks before reading — this is the
db.auto_lock_past_unlocked_plans(hid, this_monday) # historical-immutability guarantee. Once Sunday rolls over, that
# week is permanent.
db.auto_lock_past_unlocked_plans(hid, current_monday)
plan = db.get_or_create_plan(hid, this_monday) # Optional ?week=YYYY-MM-DD navigates to a specific week. Defaults
# to the current week. We snap whatever's given to the Monday of
# that week so /plan?week=2026-04-30 (a Thursday) correctly lands
# on the 2026-04-27 plan.
target_monday = current_monday
week_arg = (request.args.get("week") or "").strip()
if week_arg:
try:
parsed = date.fromisoformat(week_arg)
target_monday = monday_of(parsed)
except Exception:
pass
# Compute prev/next/today links for the nav. We always show them;
# user can navigate to any past week (read-only via auto-lock) or
# any future week (blank slate ready to plan).
prev_monday = target_monday - timedelta(days=7)
next_monday = target_monday + timedelta(days=7)
is_current_week = (target_monday == current_monday)
is_past_week = (target_monday < current_monday)
is_future_week = (target_monday > current_monday)
plan = db.get_or_create_plan(hid, target_monday)
db.enrich_plan_with_slots(plan) db.enrich_plan_with_slots(plan)
pick_count = len(db.list_household_pick_slugs(hid)) pick_count = len(db.list_household_pick_slugs(hid))
@ -629,15 +653,58 @@ def create_app() -> Flask:
return render_template( return render_template(
"plan.html", "plan.html",
week_start=plan["week_start"], week_start=plan["week_start"],
week_end=plan["week_start"] + timedelta(days=6),
plan=plan, plan=plan,
locked_by_display=locked_by_display, locked_by_display=locked_by_display,
generated_by_display=generated_by_display, generated_by_display=generated_by_display,
sub_display=sub_display, sub_display=sub_display,
current_user_sub=u["sub"], current_user_sub=u["sub"],
pick_count=pick_count, pick_count=pick_count,
prev_week=prev_monday.isoformat(),
next_week=next_monday.isoformat(),
current_week=current_monday.isoformat(),
is_current_week=is_current_week,
is_past_week=is_past_week,
is_future_week=is_future_week,
active="plan", active="plan",
) )
def _resolve_week(body: dict | None = None) -> "date":
"""Pick the target week from either body['week'] or default to today's
Monday. Used by all plan-mutation endpoints so the user can act on
any week, not just the current one."""
body = body or {}
wk = (body.get("week") or "").strip()
if wk:
try:
return monday_of(date.fromisoformat(wk))
except Exception:
pass
return monday_of(date.today())
@app.post("/api/plan/reset")
@require_session
def plan_reset():
"""Wipe an UNLOCKED week's plan back to blank slate. Locked weeks
return 409 those are immutable history (auto-locked once Sunday
rolls over). Body: {week: "YYYY-MM-DD"} optional, defaults to
current week."""
hid = current_household_id()
if not hid:
return jsonify({"error": "no household"}), 409
body = request.get_json(silent=True) or {}
target_monday = _resolve_week(body)
plan = db.get_or_create_plan(hid, target_monday)
if plan.get("locked_at"):
return jsonify({
"error": "plan_locked",
"detail": "locked weeks are immutable history",
}), 409
ok = db.reset_plan(plan["id"])
if not ok:
return jsonify({"error": "reset_failed"}), 500
return jsonify({"ok": True, "week": target_monday.isoformat()})
@app.post("/api/plan/lock") @app.post("/api/plan/lock")
@require_session @require_session
def plan_lock(): def plan_lock():
@ -645,9 +712,9 @@ def create_app() -> Flask:
hid = current_household_id() hid = current_household_id()
if not hid: if not hid:
return jsonify({"error": "no household"}), 409 return jsonify({"error": "no household"}), 409
today = date.today() body = request.get_json(silent=True) or {}
this_monday = monday_of(today) target_monday = _resolve_week(body)
plan = db.get_or_create_plan(hid, this_monday) plan = db.get_or_create_plan(hid, target_monday)
if plan.get("locked_at"): if plan.get("locked_at"):
return jsonify({"ok": False, "already_locked": True, "by": plan.get("locked_by_sub")}) return jsonify({"ok": False, "already_locked": True, "by": plan.get("locked_by_sub")})
updated = db.lock_plan(plan["id"], sub=u["sub"], reason="user") updated = db.lock_plan(plan["id"], sub=u["sub"], reason="user")
@ -661,8 +728,8 @@ def create_app() -> Flask:
if not hid: if not hid:
return jsonify({"error": "no household"}), 409 return jsonify({"error": "no household"}), 409
today = date.today() body = request.get_json(silent=True) or {}
this_monday = monday_of(today) this_monday = _resolve_week(body)
plan = db.get_or_create_plan(hid, this_monday) plan = db.get_or_create_plan(hid, this_monday)
if plan.get("locked_at"): if plan.get("locked_at"):
@ -810,15 +877,15 @@ def create_app() -> Flask:
@require_session @require_session
def plan_regenerate(): def plan_regenerate():
"""Re-roll: only the original generator can do this, only before """Re-roll: only the original generator can do this, only before
lock. Wipes slots + pick_points for this plan, then reuses the lock. Wipes slots for this plan, then reuses the generate path.
generate path. Defensive returns 409 on lock or wrong owner.""" Defensive returns 409 on lock or wrong owner."""
u = session["user"] u = session["user"]
hid = current_household_id() hid = current_household_id()
if not hid: if not hid:
return jsonify({"error": "no household"}), 409 return jsonify({"error": "no household"}), 409
today = date.today() body = request.get_json(silent=True) or {}
this_monday = monday_of(today) this_monday = _resolve_week(body)
plan = db.get_or_create_plan(hid, this_monday) plan = db.get_or_create_plan(hid, this_monday)
if plan.get("locked_at"): if plan.get("locked_at"):
@ -914,9 +981,17 @@ def create_app() -> Flask:
if not hid: if not hid:
return redirect(url_for("connect_mealie_get")) return redirect(url_for("connect_mealie_get"))
today = date.today() # Optional ?week=YYYY-MM-DD → show shopping list for that week.
this_monday = monday_of(today) # Defaults to current week. Past locked weeks render their list
plan = db.get_or_create_plan(hid, this_monday) # the same way for "what we ate then" recall.
target_monday = monday_of(date.today())
week_arg = (request.args.get("week") or "").strip()
if week_arg:
try:
target_monday = monday_of(date.fromisoformat(week_arg))
except Exception:
pass
plan = db.get_or_create_plan(hid, target_monday)
db.enrich_plan_with_slots(plan) db.enrich_plan_with_slots(plan)
if not plan.get("slots"): if not plan.get("slots"):

View file

@ -126,6 +126,42 @@
} }
.pref-chip:active { transform: scale(.97); } .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 { .pref-readout {
color: var(--bone-dim); color: var(--bone-dim);
font-family: var(--serif); font-family: var(--serif);
@ -209,8 +245,23 @@
</style> </style>
<div class="page-head"> <div class="page-head">
<div class="crumb">// plan · week of {{ week_start.strftime('%b %-d') }}</div> <div class="crumb">// plan · week of {{ week_start.strftime('%b %-d, %Y') }}</div>
<h1>this <span class="accent">week</span></h1> <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"> <div class="lede">
{% if plan.locked_at %} {% if plan.locked_at %}
locked locked
@ -339,7 +390,8 @@
{% if plan.generated_by_sub == current_user_sub %} {% if plan.generated_by_sub == current_user_sub %}
<button class="btn" type="button" onclick="rerollPlan(this)" id="reroll-btn">↻ re-roll</button> <button class="btn" type="button" onclick="rerollPlan(this)" id="reroll-btn">↻ re-roll</button>
{% endif %} {% endif %}
<a class="btn" href="/list">view list →</a> <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> </div>
{% endif %} {% endif %}
</section> </section>
@ -370,10 +422,18 @@
{% endif %} {% endif %}
<script> <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) { async function lockPlan(btn) {
btn.disabled = true; btn.textContent = '…'; btn.disabled = true; btn.textContent = '…';
try { try {
const r = await fetch('/api/plan/lock', { method: 'POST' }); 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); if (!r.ok) throw new Error(r.status);
location.reload(); location.reload();
} catch (e) { } catch (e) {
@ -381,6 +441,26 @@ async function lockPlan(btn) {
} }
} }
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) { function setPref(text) {
const el = document.getElementById('pref-input'); const el = document.getElementById('pref-input');
if (el) { el.value = text; el.focus(); } if (el) { el.value = text; el.focus(); }
@ -440,6 +520,7 @@ async function generatePlan(btn) {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
week: PLAN_WEEK,
preference: readPref(), preference: readPref(),
targets: readTargets(), targets: readTargets(),
exclusions: readExclusions(), exclusions: readExclusions(),
@ -471,7 +552,7 @@ async function rerollPlan(btn) {
const r = await fetch('/api/plan/regenerate', { const r = await fetch('/api/plan/regenerate', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), // empty body = reuse persisted constraints body: JSON.stringify({ week: PLAN_WEEK }), // reuse persisted constraints for this week
}); });
if (!r.ok) { if (!r.ok) {
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));