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:
parent
07dab10c4b
commit
a88a60e181
3 changed files with 200 additions and 21 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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(() => ({}));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue