diff --git a/cauldron/db.py b/cauldron/db.py index 5a1912a..203743b 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -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: """Lock a plan if not already locked. Returns updated plan dict.""" with self.conn() as c, c.cursor() as cur: diff --git a/cauldron/server.py b/cauldron/server.py index ce632dd..d3fe9f9 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -584,11 +584,35 @@ def create_app() -> Flask: return redirect(url_for("connect_mealie_get")) today = date.today() - this_monday = monday_of(today) - # Auto-lock any past unlocked weeks before reading - db.auto_lock_past_unlocked_plans(hid, this_monday) + current_monday = monday_of(today) + # Auto-lock any past unlocked weeks before reading — this is the + # 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) pick_count = len(db.list_household_pick_slugs(hid)) @@ -629,15 +653,58 @@ def create_app() -> Flask: return render_template( "plan.html", week_start=plan["week_start"], + week_end=plan["week_start"] + timedelta(days=6), plan=plan, locked_by_display=locked_by_display, generated_by_display=generated_by_display, sub_display=sub_display, current_user_sub=u["sub"], 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", ) + 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") @require_session def plan_lock(): @@ -645,9 +712,9 @@ def create_app() -> Flask: hid = current_household_id() if not hid: return jsonify({"error": "no household"}), 409 - today = date.today() - this_monday = monday_of(today) - plan = db.get_or_create_plan(hid, this_monday) + 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({"ok": False, "already_locked": True, "by": plan.get("locked_by_sub")}) updated = db.lock_plan(plan["id"], sub=u["sub"], reason="user") @@ -661,8 +728,8 @@ def create_app() -> Flask: if not hid: return jsonify({"error": "no household"}), 409 - today = date.today() - this_monday = monday_of(today) + body = request.get_json(silent=True) or {} + this_monday = _resolve_week(body) plan = db.get_or_create_plan(hid, this_monday) if plan.get("locked_at"): @@ -810,15 +877,15 @@ def create_app() -> Flask: @require_session def plan_regenerate(): """Re-roll: only the original generator can do this, only before - lock. Wipes slots + pick_points for this plan, then reuses the - generate path. Defensive — returns 409 on lock or wrong owner.""" + lock. Wipes slots for this plan, then reuses the generate path. + Defensive — returns 409 on lock or wrong owner.""" u = session["user"] hid = current_household_id() if not hid: return jsonify({"error": "no household"}), 409 - today = date.today() - this_monday = monday_of(today) + body = request.get_json(silent=True) or {} + this_monday = _resolve_week(body) plan = db.get_or_create_plan(hid, this_monday) if plan.get("locked_at"): @@ -914,9 +981,17 @@ def create_app() -> Flask: if not hid: return redirect(url_for("connect_mealie_get")) - today = date.today() - this_monday = monday_of(today) - plan = db.get_or_create_plan(hid, this_monday) + # Optional ?week=YYYY-MM-DD → show shopping list for that week. + # Defaults to current week. Past locked weeks render their list + # 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) if not plan.get("slots"): diff --git a/cauldron/templates/plan.html b/cauldron/templates/plan.html index 8491dc3..013ff3b 100644 --- a/cauldron/templates/plan.html +++ b/cauldron/templates/plan.html @@ -126,6 +126,42 @@ } .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 { color: var(--bone-dim); font-family: var(--serif); @@ -209,8 +245,23 @@