v0.3 step 3+4: AI plan generator + /list shopping aggregation
- 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
This commit is contained in:
parent
cc6222139d
commit
36aba73f66
9 changed files with 1724 additions and 33 deletions
229
cauldron/db.py
229
cauldron/db.py
|
|
@ -186,6 +186,48 @@ MIGRATIONS = [
|
||||||
FOREIGN KEY (cauldron_food_id) REFERENCES cauldron_foods(id) ON DELETE CASCADE
|
FOREIGN KEY (cauldron_food_id) REFERENCES cauldron_foods(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
""",
|
""",
|
||||||
|
# 012 — AI-generated meal plan slots. One row per (plan, day). Created
|
||||||
|
# when a household member triggers /api/plan/generate. picker_subs JSON
|
||||||
|
# holds the authentik_subs of household members who pinned this slot's
|
||||||
|
# recipe (empty list if AI-chosen). reason is the AI's user-facing
|
||||||
|
# rationale. notes is reserved for future swap/edit history.
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS cauldron_meal_plan_slots (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
plan_id BIGINT NOT NULL,
|
||||||
|
day VARCHAR(10) NOT NULL,
|
||||||
|
recipe_slug VARCHAR(255) NOT NULL,
|
||||||
|
recipe_name VARCHAR(500) NOT NULL,
|
||||||
|
source ENUM('mealie','pick') NOT NULL DEFAULT 'mealie',
|
||||||
|
picker_subs JSON,
|
||||||
|
reason VARCHAR(500),
|
||||||
|
notes JSON,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_plan_day (plan_id, day),
|
||||||
|
INDEX idx_plan (plan_id),
|
||||||
|
FOREIGN KEY (plan_id) REFERENCES cauldron_meal_plans(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""",
|
||||||
|
# 013 — pick-points ledger. 1pt awarded when a member's pick lands in
|
||||||
|
# a generated plan ('pick_used'). Reserved reasons for v0.4: first-to-
|
||||||
|
# lock + streak bonuses. Joins to households + plans + users so a row
|
||||||
|
# disappears cleanly if any of them are removed.
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS cauldron_pick_points (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
household_id BIGINT NOT NULL,
|
||||||
|
plan_id BIGINT NOT NULL,
|
||||||
|
authentik_sub VARCHAR(190) NOT NULL,
|
||||||
|
points INT NOT NULL,
|
||||||
|
reason ENUM('pick_used','first_to_lock','streak_bonus') NOT NULL,
|
||||||
|
awarded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_household_user (household_id, authentik_sub),
|
||||||
|
INDEX idx_plan (plan_id),
|
||||||
|
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (plan_id) REFERENCES cauldron_meal_plans(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (authentik_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -393,8 +435,19 @@ class DB:
|
||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
def household_scoreboard(self, household_id: int) -> list[dict]:
|
def household_scoreboard(self, household_id: int) -> list[dict]:
|
||||||
"""Per-user lock counts + most recent lock time. Joins to users for
|
"""Per-user lock counts + pick-points + most recent lock time.
|
||||||
display name. Excludes auto-locks (those are no-one's win)."""
|
|
||||||
|
Three numbers per row:
|
||||||
|
wins — user-locked weeks (excludes auto-locks)
|
||||||
|
weeks_locked — alias for wins, preserved for older callers
|
||||||
|
points — sum of cauldron_pick_points for this user/household
|
||||||
|
|
||||||
|
Sort: points desc, then wins desc, then last_win desc — points are
|
||||||
|
the headline metric in v0.3 (every pick lands → matters daily).
|
||||||
|
|
||||||
|
We compute lock counts and points as separate scalar subqueries so
|
||||||
|
the JOIN doesn't blow up on the cartesian (members × plans × points).
|
||||||
|
"""
|
||||||
with self.conn() as c, c.cursor() as cur:
|
with self.conn() as c, c.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -402,22 +455,39 @@ class DB:
|
||||||
u.authentik_sub AS sub,
|
u.authentik_sub AS sub,
|
||||||
u.email AS email,
|
u.email AS email,
|
||||||
u.display_name AS display_name,
|
u.display_name AS display_name,
|
||||||
COUNT(p.id) AS wins,
|
COALESCE((
|
||||||
MAX(p.locked_at) AS last_win
|
SELECT COUNT(*) FROM cauldron_meal_plans p
|
||||||
|
WHERE p.locked_by_sub = m.authentik_sub
|
||||||
|
AND p.household_id = m.household_id
|
||||||
|
AND p.locked_reason = 'user'
|
||||||
|
), 0) AS wins,
|
||||||
|
(
|
||||||
|
SELECT MAX(p.locked_at) FROM cauldron_meal_plans p
|
||||||
|
WHERE p.locked_by_sub = m.authentik_sub
|
||||||
|
AND p.household_id = m.household_id
|
||||||
|
AND p.locked_reason = 'user'
|
||||||
|
) AS last_win,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(pp.points) FROM cauldron_pick_points pp
|
||||||
|
WHERE pp.household_id = m.household_id
|
||||||
|
AND pp.authentik_sub = m.authentik_sub
|
||||||
|
), 0) AS points
|
||||||
FROM cauldron_household_members m
|
FROM cauldron_household_members m
|
||||||
LEFT JOIN cauldron_users u
|
LEFT JOIN cauldron_users u
|
||||||
ON u.authentik_sub = m.authentik_sub
|
ON u.authentik_sub = m.authentik_sub
|
||||||
LEFT JOIN cauldron_meal_plans p
|
|
||||||
ON p.locked_by_sub = m.authentik_sub
|
|
||||||
AND p.household_id = m.household_id
|
|
||||||
AND p.locked_reason = 'user'
|
|
||||||
WHERE m.household_id = %s
|
WHERE m.household_id = %s
|
||||||
GROUP BY u.authentik_sub, u.email, u.display_name
|
ORDER BY points DESC, wins DESC, last_win DESC
|
||||||
ORDER BY wins DESC, last_win DESC
|
|
||||||
""",
|
""",
|
||||||
(household_id,),
|
(household_id,),
|
||||||
)
|
)
|
||||||
return [dict(r) for r in cur.fetchall()]
|
out = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
d = dict(r)
|
||||||
|
d["points"] = int(d.get("points") or 0)
|
||||||
|
d["wins"] = int(d.get("wins") or 0)
|
||||||
|
d["weeks_locked"] = d["wins"]
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
def household_streak(self, household_id: int) -> dict | None:
|
def household_streak(self, household_id: int) -> dict | None:
|
||||||
"""Compute current win streak: walk back from most recent locked week,
|
"""Compute current win streak: walk back from most recent locked week,
|
||||||
|
|
@ -451,6 +521,143 @@ class DB:
|
||||||
"count": count,
|
"count": count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- plan slots + pick points (v0.3 A4) --------------------------------
|
||||||
|
|
||||||
|
# Day order is stable Mon..Sun. Used everywhere we need to render slots
|
||||||
|
# in calendar order.
|
||||||
|
PLAN_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
||||||
|
|
||||||
|
def list_plan_slots(self, plan_id: int) -> list[dict]:
|
||||||
|
"""All slots for a plan, ordered Mon..Sun. picker_subs is decoded
|
||||||
|
from JSON to a list (or [] if null)."""
|
||||||
|
import json as _json
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, plan_id, day, recipe_slug, recipe_name, source,
|
||||||
|
picker_subs, reason, notes, created_at
|
||||||
|
FROM cauldron_meal_plan_slots
|
||||||
|
WHERE plan_id = %s
|
||||||
|
""",
|
||||||
|
(plan_id,),
|
||||||
|
)
|
||||||
|
rows = [dict(r) for r in cur.fetchall()]
|
||||||
|
for r in rows:
|
||||||
|
ps = r.get("picker_subs")
|
||||||
|
if isinstance(ps, str):
|
||||||
|
try:
|
||||||
|
r["picker_subs"] = _json.loads(ps)
|
||||||
|
except Exception:
|
||||||
|
r["picker_subs"] = []
|
||||||
|
elif ps is None:
|
||||||
|
r["picker_subs"] = []
|
||||||
|
n = r.get("notes")
|
||||||
|
if isinstance(n, str):
|
||||||
|
try:
|
||||||
|
r["notes"] = _json.loads(n)
|
||||||
|
except Exception:
|
||||||
|
r["notes"] = None
|
||||||
|
order = {d: i for i, d in enumerate(self.PLAN_DAYS)}
|
||||||
|
rows.sort(key=lambda r: order.get((r.get("day") or "").lower(), 99))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def save_plan_slots(self, plan_id: int, slots: list[dict]) -> int:
|
||||||
|
"""INSERT IGNORE every slot. Returns count actually inserted —
|
||||||
|
callers can use this to detect race contention (zero rows = someone
|
||||||
|
else already saved this plan)."""
|
||||||
|
import json as _json
|
||||||
|
if not slots:
|
||||||
|
return 0
|
||||||
|
inserted = 0
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
for s in slots:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT IGNORE INTO cauldron_meal_plan_slots
|
||||||
|
(plan_id, day, recipe_slug, recipe_name, source,
|
||||||
|
picker_subs, reason, notes)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
plan_id,
|
||||||
|
(s.get("day") or "").lower()[:10],
|
||||||
|
s["recipe_slug"][:255],
|
||||||
|
(s.get("recipe_name") or s["recipe_slug"])[:500],
|
||||||
|
s.get("source") or ("pick" if s.get("picker_subs") else "mealie"),
|
||||||
|
_json.dumps(s.get("picker_subs") or []),
|
||||||
|
(s.get("reason") or "")[:500] or None,
|
||||||
|
_json.dumps(s["notes"]) if s.get("notes") is not None else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += cur.rowcount
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
def delete_plan_slots(self, plan_id: int) -> int:
|
||||||
|
"""Wipe slots for a plan (used by re-roll). Also nukes the matching
|
||||||
|
pick_points so the ledger doesn't double-count on regenerate."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute("DELETE FROM cauldron_meal_plan_slots WHERE plan_id=%s", (plan_id,))
|
||||||
|
slots_removed = cur.rowcount
|
||||||
|
cur.execute("DELETE FROM cauldron_pick_points WHERE plan_id=%s", (plan_id,))
|
||||||
|
return slots_removed
|
||||||
|
|
||||||
|
def mark_plan_generated(self, plan_id: int, sub: str) -> dict:
|
||||||
|
"""Set generated_by_sub + generated_at IF not already set. Returns
|
||||||
|
the post-update plan row. Idempotent for the same generator."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cauldron_meal_plans
|
||||||
|
SET generated_by_sub = %s,
|
||||||
|
generated_at = NOW()
|
||||||
|
WHERE id = %s AND generated_at IS NULL
|
||||||
|
""",
|
||||||
|
(sub, plan_id),
|
||||||
|
)
|
||||||
|
cur.execute("SELECT * FROM cauldron_meal_plans WHERE id=%s", (plan_id,))
|
||||||
|
return dict(cur.fetchone())
|
||||||
|
|
||||||
|
def clear_plan_generated(self, plan_id: int) -> None:
|
||||||
|
"""Re-roll path: clear the generated_by/at marker so the next
|
||||||
|
generate writes fresh metadata."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cauldron_meal_plans
|
||||||
|
SET generated_by_sub = NULL,
|
||||||
|
generated_at = NULL
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(plan_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def award_pick_points(
|
||||||
|
self,
|
||||||
|
household_id: int,
|
||||||
|
plan_id: int,
|
||||||
|
sub: str,
|
||||||
|
points: int,
|
||||||
|
reason: str = "pick_used",
|
||||||
|
) -> int:
|
||||||
|
"""Insert one ledger row. Returns the new row id. Reason must be one
|
||||||
|
of the ENUM values; we don't validate here — DB will reject bad ones."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cauldron_pick_points
|
||||||
|
(household_id, plan_id, authentik_sub, points, reason)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(household_id, plan_id, sub, int(points), reason),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
def enrich_plan_with_slots(self, plan: dict) -> dict:
|
||||||
|
"""In-place: add `slots` key to a plan dict. Returns the same dict
|
||||||
|
for chaining. Empty list if there are no slots yet."""
|
||||||
|
plan["slots"] = self.list_plan_slots(plan["id"]) if plan.get("id") else []
|
||||||
|
return plan
|
||||||
|
|
||||||
# --- meal picks ---------------------------------------------------------
|
# --- meal picks ---------------------------------------------------------
|
||||||
|
|
||||||
def add_meal_pick(self, sub: str, slug: str, name: str) -> bool:
|
def add_meal_pick(self, sub: str, slug: str, name: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
"""Thin HTTP client for clawdforge — we're a consumer."""
|
"""Thin HTTP client for clawdforge — we're a consumer."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -6,6 +9,9 @@ class ForgeError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
|
||||||
|
|
||||||
|
|
||||||
class Forge:
|
class Forge:
|
||||||
def __init__(self, *, base_url: str, token: str, default_model: str, default_timeout: int):
|
def __init__(self, *, base_url: str, token: str, default_model: str, default_timeout: int):
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
|
|
@ -62,3 +68,175 @@ class Forge:
|
||||||
raise ForgeError(f"upstream {r.status_code}: {r.text[:500]}")
|
raise ForgeError(f"upstream {r.status_code}: {r.text[:500]}")
|
||||||
|
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def generate_plan(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
picks: list[dict],
|
||||||
|
recipes: list[dict],
|
||||||
|
slots: int = 7,
|
||||||
|
week_start: str,
|
||||||
|
model: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
||||||
|
shaped like:
|
||||||
|
{"day": "monday", "recipe_slug": "...", "recipe_name": "...",
|
||||||
|
"picker_subs": [...], "reason": "...", "source": "pick"|"mealie"}
|
||||||
|
|
||||||
|
Validates structure aggressively — wrong shape / wrong slot count /
|
||||||
|
slug-not-in-pool → ForgeError. Caller surfaces a 502 to the user.
|
||||||
|
|
||||||
|
recipes: [{slug, name, tags?}], picks: [{slug, name, picker_subs}].
|
||||||
|
Picks are the family's pinned recipes; the prompt mandates each one
|
||||||
|
appears exactly once when the pool is large enough.
|
||||||
|
"""
|
||||||
|
if slots < 1 or slots > 14:
|
||||||
|
raise ForgeError(f"bad slot count: {slots}")
|
||||||
|
if not recipes:
|
||||||
|
raise ForgeError("recipe pool empty — cannot generate")
|
||||||
|
|
||||||
|
# Build a slug → name map for validation. Use the recipe pool plus
|
||||||
|
# picks (picks should already be in the pool, but be defensive).
|
||||||
|
valid_by_slug: dict[str, str] = {}
|
||||||
|
for r in recipes:
|
||||||
|
slug = r.get("slug")
|
||||||
|
if slug:
|
||||||
|
valid_by_slug[slug] = r.get("name") or slug
|
||||||
|
for p in picks:
|
||||||
|
slug = p.get("slug")
|
||||||
|
if slug:
|
||||||
|
valid_by_slug.setdefault(slug, p.get("name") or slug)
|
||||||
|
|
||||||
|
prompt = self._build_plan_prompt(
|
||||||
|
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
|
||||||
|
)
|
||||||
|
result = self.run(prompt, model=model or "sonnet")
|
||||||
|
parsed = _extract_plan_slots(result)
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
raise ForgeError("model output: 'slots' must be a list")
|
||||||
|
if len(parsed) != slots:
|
||||||
|
raise ForgeError(f"model output: got {len(parsed)} slots, expected {slots}")
|
||||||
|
|
||||||
|
# Pick attribution lookup keyed by slug → list[sub]
|
||||||
|
pick_subs_by_slug: dict[str, list[str]] = {}
|
||||||
|
for p in picks:
|
||||||
|
slug = p.get("slug")
|
||||||
|
if slug:
|
||||||
|
pick_subs_by_slug[slug] = list(p.get("picker_subs") or [])
|
||||||
|
|
||||||
|
out = []
|
||||||
|
seen_days: set[str] = set()
|
||||||
|
for raw in parsed:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ForgeError("model output: each slot must be an object")
|
||||||
|
day = (raw.get("day") or "").strip().lower()
|
||||||
|
slug = (raw.get("recipe_slug") or "").strip()
|
||||||
|
if day not in _DAYS:
|
||||||
|
raise ForgeError(f"model output: bad day '{day}'")
|
||||||
|
if day in seen_days:
|
||||||
|
raise ForgeError(f"model output: duplicate day '{day}'")
|
||||||
|
seen_days.add(day)
|
||||||
|
if not slug or slug not in valid_by_slug:
|
||||||
|
raise ForgeError(f"model output: unknown recipe_slug '{slug}'")
|
||||||
|
|
||||||
|
# Trust the model's picker_subs only if they intersect the real
|
||||||
|
# set. We have ground truth in pick_subs_by_slug — prefer it.
|
||||||
|
real_pickers = pick_subs_by_slug.get(slug, [])
|
||||||
|
model_pickers = raw.get("picker_subs") or []
|
||||||
|
if not isinstance(model_pickers, list):
|
||||||
|
model_pickers = []
|
||||||
|
picker_subs = real_pickers if real_pickers else [
|
||||||
|
s for s in model_pickers if isinstance(s, str)
|
||||||
|
]
|
||||||
|
source = "pick" if real_pickers else "mealie"
|
||||||
|
|
||||||
|
out.append({
|
||||||
|
"day": day,
|
||||||
|
"recipe_slug": slug,
|
||||||
|
"recipe_name": valid_by_slug[slug],
|
||||||
|
"picker_subs": picker_subs,
|
||||||
|
"reason": (raw.get("reason") or "")[:500],
|
||||||
|
"source": source,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_plan_prompt(*, picks, recipes, slots, week_start) -> str:
|
||||||
|
pool_lines = []
|
||||||
|
for r in recipes:
|
||||||
|
slug = r.get("slug") or ""
|
||||||
|
name = r.get("name") or slug
|
||||||
|
tags = r.get("tags") or []
|
||||||
|
tag_str = ""
|
||||||
|
if tags:
|
||||||
|
# First 3 tags only — keeps prompt token count under control
|
||||||
|
cleaned = []
|
||||||
|
for t in tags[:3]:
|
||||||
|
if isinstance(t, dict):
|
||||||
|
cleaned.append(t.get("name") or "")
|
||||||
|
elif isinstance(t, str):
|
||||||
|
cleaned.append(t)
|
||||||
|
cleaned = [c for c in cleaned if c]
|
||||||
|
if cleaned:
|
||||||
|
tag_str = f" [{', '.join(cleaned)}]"
|
||||||
|
pool_lines.append(f"- {slug}: {name}{tag_str}")
|
||||||
|
|
||||||
|
pick_lines = []
|
||||||
|
for p in picks:
|
||||||
|
slug = p.get("slug") or ""
|
||||||
|
name = p.get("name") or slug
|
||||||
|
pickers = p.get("pickers") or []
|
||||||
|
picker_subs = p.get("picker_subs") or []
|
||||||
|
who = ", ".join(pickers) if pickers else "household"
|
||||||
|
subs_repr = json.dumps(picker_subs)
|
||||||
|
pick_lines.append(f"- {slug}: {name} (picked by [{who}], picker_subs={subs_repr})")
|
||||||
|
|
||||||
|
picks_block = "\n".join(pick_lines) if pick_lines else "(none)"
|
||||||
|
pool_block = "\n".join(pool_lines)
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"You are a family meal planner. Build a {slots}-day dinner plan "
|
||||||
|
f"for the week of {week_start}.\n\n"
|
||||||
|
f"POOL (all available recipes):\n{pool_block}\n\n"
|
||||||
|
f"PICKS (recipes the family pre-selected — every pick MUST appear "
|
||||||
|
f"if pool size >= {slots}; no repeats unless pool < {slots}):\n"
|
||||||
|
f"{picks_block}\n\n"
|
||||||
|
"Output JSON ONLY, no prose: "
|
||||||
|
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
||||||
|
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
|
||||||
|
"Rules:\n"
|
||||||
|
f"- Use exactly {slots} recipes\n"
|
||||||
|
"- Distribute picks evenly across the week — don't bunch them\n"
|
||||||
|
"- \"reason\" is a one-line user-facing rationale "
|
||||||
|
"(e.g., \"balances heavy and light meals\", \"honors abby's pick\")\n"
|
||||||
|
"- \"picker_subs\" is the array of authentik_sub strings of family "
|
||||||
|
"members who picked this recipe (empty list if AI-chosen)\n"
|
||||||
|
"- Day order: monday..sunday\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_plan_slots(forge_result: dict):
|
||||||
|
"""clawdforge wraps its return; the JSON we asked for can sit in a few
|
||||||
|
different shapes. Normalize aggressively."""
|
||||||
|
if not isinstance(forge_result, dict):
|
||||||
|
raise ForgeError("forge result not a dict")
|
||||||
|
inner = forge_result.get("result", forge_result)
|
||||||
|
# `result` may be a string when claude returned non-JSON — try to scrape
|
||||||
|
if isinstance(inner, str):
|
||||||
|
inner = _parse_json_blob(inner)
|
||||||
|
if isinstance(inner, dict) and "slots" in inner:
|
||||||
|
return inner["slots"]
|
||||||
|
if isinstance(inner, list):
|
||||||
|
return inner
|
||||||
|
raise ForgeError(f"forge result missing 'slots' key: {str(inner)[:200]}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_blob(s: str):
|
||||||
|
s = s.strip()
|
||||||
|
# Strip code fences if Sonnet wrapped its output
|
||||||
|
s = re.sub(r"^```(?:json)?\s*", "", s)
|
||||||
|
s = re.sub(r"\s*```$", "", s)
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except Exception as e:
|
||||||
|
raise ForgeError(f"could not parse model JSON: {e}; head={s[:200]!r}") from e
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ from flask import Flask, jsonify, redirect, render_template, request, session, u
|
||||||
from .config import load
|
from .config import load
|
||||||
from .crypto import TokenCrypto
|
from .crypto import TokenCrypto
|
||||||
from .db import DB
|
from .db import DB
|
||||||
from .forge import Forge
|
from .forge import Forge, ForgeError
|
||||||
from . import foods
|
from . import aggregator, foods
|
||||||
from .mealie import Mealie, MealieError
|
from .mealie import Mealie, MealieError
|
||||||
from .oidc import init_oauth
|
from .oidc import init_oauth
|
||||||
from .recipe_index import flatten_recipe, refresh_household_index, search_index
|
from .recipe_index import flatten_recipe, refresh_household_index, search_index
|
||||||
|
|
@ -500,27 +500,29 @@ def create_app() -> Flask:
|
||||||
db.auto_lock_past_unlocked_plans(hid, this_monday)
|
db.auto_lock_past_unlocked_plans(hid, this_monday)
|
||||||
|
|
||||||
plan = db.get_or_create_plan(hid, this_monday)
|
plan = db.get_or_create_plan(hid, this_monday)
|
||||||
|
db.enrich_plan_with_slots(plan)
|
||||||
scoreboard = db.household_scoreboard(hid)
|
scoreboard = db.household_scoreboard(hid)
|
||||||
streak = db.household_streak(hid)
|
streak = db.household_streak(hid)
|
||||||
pick_count = len(db.list_household_pick_slugs(hid))
|
pick_count = len(db.list_household_pick_slugs(hid))
|
||||||
|
|
||||||
# Look up display name for locked_by
|
# Resolve display names for any subs we render — locked_by + every
|
||||||
|
# picker referenced by any slot. One round-trip; small set.
|
||||||
|
sub_display: dict[str, str] = _resolve_sub_displays(db, plan)
|
||||||
locked_by_display = None
|
locked_by_display = None
|
||||||
if plan.get("locked_by_sub"):
|
if plan.get("locked_by_sub"):
|
||||||
with db.conn() as c, c.cursor() as cur:
|
locked_by_display = sub_display.get(plan["locked_by_sub"])
|
||||||
cur.execute(
|
|
||||||
"SELECT display_name, email FROM cauldron_users WHERE authentik_sub=%s",
|
generated_by_display = None
|
||||||
(plan["locked_by_sub"],),
|
if plan.get("generated_by_sub"):
|
||||||
)
|
generated_by_display = sub_display.get(plan["generated_by_sub"])
|
||||||
r = cur.fetchone()
|
|
||||||
if r:
|
|
||||||
locked_by_display = r["display_name"] or (r["email"] or "").split("@")[0]
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"plan.html",
|
"plan.html",
|
||||||
week_start=plan["week_start"],
|
week_start=plan["week_start"],
|
||||||
plan=plan,
|
plan=plan,
|
||||||
locked_by_display=locked_by_display,
|
locked_by_display=locked_by_display,
|
||||||
|
generated_by_display=generated_by_display,
|
||||||
|
sub_display=sub_display,
|
||||||
scoreboard=scoreboard,
|
scoreboard=scoreboard,
|
||||||
streak=streak,
|
streak=streak,
|
||||||
current_user_sub=u["sub"],
|
current_user_sub=u["sub"],
|
||||||
|
|
@ -543,10 +545,211 @@ def create_app() -> Flask:
|
||||||
updated = db.lock_plan(plan["id"], sub=u["sub"], reason="user")
|
updated = db.lock_plan(plan["id"], sub=u["sub"], reason="user")
|
||||||
return jsonify({"ok": True, "locked_at": updated["locked_at"].isoformat() if updated["locked_at"] else None})
|
return jsonify({"ok": True, "locked_at": updated["locked_at"].isoformat() if updated["locked_at"] else None})
|
||||||
|
|
||||||
|
@app.post("/api/plan/generate")
|
||||||
|
@require_session
|
||||||
|
def plan_generate():
|
||||||
|
u = session["user"]
|
||||||
|
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)
|
||||||
|
|
||||||
|
if plan.get("locked_at"):
|
||||||
|
return jsonify({
|
||||||
|
"error": "plan_locked",
|
||||||
|
"locked_by_sub": plan.get("locked_by_sub"),
|
||||||
|
"locked_at": plan["locked_at"].isoformat() if plan.get("locked_at") else None,
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Idempotency / race: if slots already exist, return them. Two
|
||||||
|
# concurrent generators end up with the first writer's slots; the
|
||||||
|
# second sees them and returns 409 with the existing plan.
|
||||||
|
existing = db.list_plan_slots(plan["id"])
|
||||||
|
if existing:
|
||||||
|
db.enrich_plan_with_slots(plan)
|
||||||
|
return jsonify({
|
||||||
|
"error": "plan_already_generated",
|
||||||
|
"plan": _plan_payload(plan),
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Pull picks (with picker_subs) + recipe pool (slug+name+tags only)
|
||||||
|
picks = db.list_household_picks_with_pickers(hid)
|
||||||
|
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
|
||||||
|
recipes = []
|
||||||
|
for r in rows:
|
||||||
|
tags = []
|
||||||
|
raw = r.get("raw_json")
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
raw = _json_loads(raw)
|
||||||
|
except Exception:
|
||||||
|
raw = None
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
tags = raw.get("tags") or []
|
||||||
|
recipes.append({"slug": r["slug"], "name": r["name"], "tags": tags})
|
||||||
|
|
||||||
|
if not recipes:
|
||||||
|
return jsonify({"error": "no_recipes_indexed"}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
slots = forge.generate_plan(
|
||||||
|
picks=picks, recipes=recipes,
|
||||||
|
slots=7, week_start=this_monday.isoformat(),
|
||||||
|
)
|
||||||
|
except ForgeError as e:
|
||||||
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
||||||
|
inserted = db.save_plan_slots(plan["id"], slots)
|
||||||
|
if inserted == 0:
|
||||||
|
# Race lost — re-read and return the winner's plan
|
||||||
|
db.enrich_plan_with_slots(plan)
|
||||||
|
return jsonify({
|
||||||
|
"error": "plan_already_generated",
|
||||||
|
"plan": _plan_payload(plan),
|
||||||
|
}), 409
|
||||||
|
|
||||||
|
# Award 1pt per pick that landed (one row per (sub, pick_used) per
|
||||||
|
# plan, deduped client-side: a pick with two pickers gives both 1pt).
|
||||||
|
for s in slots:
|
||||||
|
for sub in s.get("picker_subs") or []:
|
||||||
|
try:
|
||||||
|
db.award_pick_points(hid, plan["id"], sub, 1, "pick_used")
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.warning("award_pick_points failed for %s: %s", sub, exc)
|
||||||
|
|
||||||
|
plan = db.mark_plan_generated(plan["id"], u["sub"])
|
||||||
|
db.enrich_plan_with_slots(plan)
|
||||||
|
return jsonify({"ok": True, "plan": _plan_payload(plan)})
|
||||||
|
|
||||||
|
@app.post("/api/plan/regenerate")
|
||||||
|
@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."""
|
||||||
|
u = session["user"]
|
||||||
|
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)
|
||||||
|
|
||||||
|
if plan.get("locked_at"):
|
||||||
|
return jsonify({"error": "plan_locked"}), 409
|
||||||
|
if not plan.get("generated_at"):
|
||||||
|
# Nothing to re-roll — caller probably wanted plain /generate
|
||||||
|
return jsonify({"error": "plan_not_generated"}), 409
|
||||||
|
if plan.get("generated_by_sub") != u["sub"]:
|
||||||
|
return jsonify({"error": "not_generator"}), 403
|
||||||
|
|
||||||
|
db.delete_plan_slots(plan["id"])
|
||||||
|
db.clear_plan_generated(plan["id"])
|
||||||
|
|
||||||
|
# Now fall through to the same logic as generate
|
||||||
|
picks = db.list_household_picks_with_pickers(hid)
|
||||||
|
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
|
||||||
|
recipes = []
|
||||||
|
for r in rows:
|
||||||
|
tags = []
|
||||||
|
raw = r.get("raw_json")
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
raw = _json_loads(raw)
|
||||||
|
except Exception:
|
||||||
|
raw = None
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
tags = raw.get("tags") or []
|
||||||
|
recipes.append({"slug": r["slug"], "name": r["name"], "tags": tags})
|
||||||
|
|
||||||
|
if not recipes:
|
||||||
|
return jsonify({"error": "no_recipes_indexed"}), 409
|
||||||
|
|
||||||
|
try:
|
||||||
|
slots = forge.generate_plan(
|
||||||
|
picks=picks, recipes=recipes,
|
||||||
|
slots=7, week_start=this_monday.isoformat(),
|
||||||
|
)
|
||||||
|
except ForgeError as e:
|
||||||
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
||||||
|
db.save_plan_slots(plan["id"], slots)
|
||||||
|
for s in slots:
|
||||||
|
for sub in s.get("picker_subs") or []:
|
||||||
|
try:
|
||||||
|
db.award_pick_points(hid, plan["id"], sub, 1, "pick_used")
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.warning("award_pick_points failed for %s: %s", sub, exc)
|
||||||
|
plan = db.mark_plan_generated(plan["id"], u["sub"])
|
||||||
|
db.enrich_plan_with_slots(plan)
|
||||||
|
return jsonify({"ok": True, "plan": _plan_payload(plan)})
|
||||||
|
|
||||||
@app.get("/list")
|
@app.get("/list")
|
||||||
@require_session
|
@require_session
|
||||||
def list_view():
|
def list_view():
|
||||||
return render_template("stub.html", title="list", coming="aggregated shopping list from this week's plan", active="list")
|
u = session["user"]
|
||||||
|
hid = current_household_id()
|
||||||
|
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)
|
||||||
|
db.enrich_plan_with_slots(plan)
|
||||||
|
|
||||||
|
if not plan.get("slots"):
|
||||||
|
return render_template(
|
||||||
|
"list.html",
|
||||||
|
plan=plan, lines=[], active="list",
|
||||||
|
empty_reason="no_plan",
|
||||||
|
missing_recipes=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
client = current_user_mealie()
|
||||||
|
if not client:
|
||||||
|
return redirect(url_for("connect_mealie_get"))
|
||||||
|
|
||||||
|
raw_ings: list[aggregator.Ingredient] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
for s in plan["slots"]:
|
||||||
|
try:
|
||||||
|
recipe = client.get_recipe(s["recipe_slug"])
|
||||||
|
except Exception:
|
||||||
|
missing.append(s["recipe_slug"])
|
||||||
|
continue
|
||||||
|
for ri in recipe.get("recipeIngredient", []) or []:
|
||||||
|
qty = ri.get("quantity")
|
||||||
|
unit_obj = ri.get("unit") or {}
|
||||||
|
unit = (unit_obj.get("name") if isinstance(unit_obj, dict) else "") or ""
|
||||||
|
food_obj = ri.get("food") or {}
|
||||||
|
food_name = (food_obj.get("name") if isinstance(food_obj, dict) else "") or ""
|
||||||
|
note = ri.get("note") or ""
|
||||||
|
if not food_name and not note:
|
||||||
|
continue
|
||||||
|
raw_ings.append(aggregator.Ingredient(
|
||||||
|
qty=float(qty) if qty not in (None, "") else None,
|
||||||
|
unit=unit,
|
||||||
|
food_name=food_name or note,
|
||||||
|
note=note if food_name else None,
|
||||||
|
source_recipe_slug=s["recipe_slug"],
|
||||||
|
original_text=ri.get("display") or _ing_render(qty, unit, food_name, note),
|
||||||
|
))
|
||||||
|
|
||||||
|
def foods_lookup(name: str):
|
||||||
|
hits = foods.search_food(db, name, limit=1)
|
||||||
|
return hits[0] if hits else None
|
||||||
|
|
||||||
|
lines = aggregator.aggregate(raw_ings, foods_lookup)
|
||||||
|
return render_template(
|
||||||
|
"list.html",
|
||||||
|
plan=plan, lines=lines, active="list",
|
||||||
|
empty_reason=None,
|
||||||
|
missing_recipes=missing,
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/api/recipes/<slug>.json")
|
@app.get("/api/recipes/<slug>.json")
|
||||||
@require_session
|
@require_session
|
||||||
|
|
@ -714,5 +917,74 @@ def _const_eq(a: str, b: str) -> bool:
|
||||||
return diff == 0
|
return diff == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _json_loads(s):
|
||||||
|
import json as _j
|
||||||
|
return _j.loads(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_sub_displays(db, plan: dict) -> dict[str, str]:
|
||||||
|
"""Return {sub: display_name} for every sub referenced by this plan's
|
||||||
|
slots + locked_by + generated_by. One DB round-trip; small set."""
|
||||||
|
subs: set[str] = set()
|
||||||
|
if plan.get("locked_by_sub"):
|
||||||
|
subs.add(plan["locked_by_sub"])
|
||||||
|
if plan.get("generated_by_sub"):
|
||||||
|
subs.add(plan["generated_by_sub"])
|
||||||
|
for s in plan.get("slots") or []:
|
||||||
|
for sub in s.get("picker_subs") or []:
|
||||||
|
if sub:
|
||||||
|
subs.add(sub)
|
||||||
|
if not subs:
|
||||||
|
return {}
|
||||||
|
placeholders = ", ".join(["%s"] * len(subs))
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
with db.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT authentik_sub, display_name, email FROM cauldron_users "
|
||||||
|
f"WHERE authentik_sub IN ({placeholders})",
|
||||||
|
tuple(subs),
|
||||||
|
)
|
||||||
|
for r in cur.fetchall():
|
||||||
|
sub = r["authentik_sub"]
|
||||||
|
disp = r.get("display_name")
|
||||||
|
if not disp:
|
||||||
|
disp = (r.get("email") or "").split("@")[0]
|
||||||
|
out[sub] = disp or sub
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_payload(plan: dict) -> dict:
|
||||||
|
"""JSON-serializable view of a plan dict (datetimes → iso strings)."""
|
||||||
|
p = dict(plan)
|
||||||
|
for k in ("week_start", "generated_at", "locked_at"):
|
||||||
|
v = p.get(k)
|
||||||
|
if v is not None and hasattr(v, "isoformat"):
|
||||||
|
p[k] = v.isoformat()
|
||||||
|
slots = p.get("slots") or []
|
||||||
|
out_slots = []
|
||||||
|
for s in slots:
|
||||||
|
s2 = dict(s)
|
||||||
|
ca = s2.get("created_at")
|
||||||
|
if ca is not None and hasattr(ca, "isoformat"):
|
||||||
|
s2["created_at"] = ca.isoformat()
|
||||||
|
out_slots.append(s2)
|
||||||
|
p["slots"] = out_slots
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _ing_render(qty, unit, food_name, note) -> str:
|
||||||
|
"""Tiny fallback for `display` when Mealie didn't render the line."""
|
||||||
|
parts: list[str] = []
|
||||||
|
if qty not in (None, ""):
|
||||||
|
parts.append(str(qty))
|
||||||
|
if unit:
|
||||||
|
parts.append(unit)
|
||||||
|
if food_name:
|
||||||
|
parts.append(food_name)
|
||||||
|
if note and not food_name:
|
||||||
|
parts.append(note)
|
||||||
|
return " ".join(parts).strip()
|
||||||
|
|
||||||
|
|
||||||
# gunicorn entrypoint
|
# gunicorn entrypoint
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
|
||||||
229
cauldron/templates/list.html
Normal file
229
cauldron/templates/list.html
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
{% 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 %}
|
||||||
|
|
@ -2,6 +2,83 @@
|
||||||
{% block title %}This Week · Cauldron{% endblock %}
|
{% block title %}This Week · Cauldron{% endblock %}
|
||||||
{% block content %}
|
{% 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="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') }}</div>
|
||||||
<h1>this <span class="accent">week</span></h1>
|
<h1>this <span class="accent">week</span></h1>
|
||||||
|
|
@ -14,8 +91,11 @@
|
||||||
automatically
|
automatically
|
||||||
{% endif %}
|
{% endif %}
|
||||||
· {{ plan.locked_at.strftime('%a %-I:%M %p') }}
|
· {{ 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 %}
|
{% else %}
|
||||||
pool has {{ pick_count }} pinned. ai generation lands in v0.3 — for now, lock when ready to mark this week done.
|
{{ pick_count }} pinned in the pool. summon the planner to build a 7-day plan, then race to lock.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,6 +105,8 @@
|
||||||
<h2>state</h2>
|
<h2>state</h2>
|
||||||
{% if plan.locked_at %}
|
{% if plan.locked_at %}
|
||||||
<span class="pill pill-mute">{{ 'auto-locked' if plan.locked_reason == 'auto' else 'locked' }}</span>
|
<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 %}
|
{% else %}
|
||||||
<span class="pill pill-ok">open</span>
|
<span class="pill pill-ok">open</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -36,19 +118,51 @@
|
||||||
{% if plan.locked_reason == 'user' and plan.locked_by_sub == current_user_sub %}
|
{% if plan.locked_reason == 'user' and plan.locked_by_sub == current_user_sub %}
|
||||||
<p class="muted">🏆 you locked this one in.</p>
|
<p class="muted">🏆 you locked this one in.</p>
|
||||||
{% endif %}
|
{% 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 %}
|
{% else %}
|
||||||
<p>nothing's stopping you from locking right now. or wait — generator's coming and the race is part of the deal.</p>
|
<p>plan's set. lock it in to claim the week — first to lock wins.</p>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
|
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
|
||||||
<a class="btn" href="/picks">view pool ({{ pick_count }})</a>
|
{% 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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>scoreboard</h2>
|
<h2>scoreboard</h2>
|
||||||
<span class="ctx">user-locked weeks only</span>
|
<span class="ctx">picks landed · weeks won</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if streak and streak.count >= 2 %}
|
{% if streak and streak.count >= 2 %}
|
||||||
|
|
@ -58,11 +172,12 @@
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if scoreboard and scoreboard|selectattr("wins")|list %}
|
{% if scoreboard and scoreboard|selectattr("points")|list or scoreboard and scoreboard|selectattr("wins")|list %}
|
||||||
<table style="width: 100%; border-collapse: collapse; font-size: .95em;">
|
<table style="width: 100%; border-collapse: collapse; font-size: .95em;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="border-bottom: 1px solid var(--line);">
|
<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: 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;">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>
|
<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>
|
</tr>
|
||||||
|
|
@ -72,11 +187,12 @@
|
||||||
<tr style="border-bottom: 1px solid var(--line-soft);">
|
<tr style="border-bottom: 1px solid var(--line-soft);">
|
||||||
<td style="padding: 8px 0;">
|
<td style="padding: 8px 0;">
|
||||||
{{ loop.index }}.
|
{{ loop.index }}.
|
||||||
<strong style="color: {% if loop.first and s.wins %}var(--green-bright){% else %}var(--bone){% endif %};">
|
<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] }}
|
{{ s.display_name or s.email.split('@')[0] }}
|
||||||
</strong>
|
</strong>
|
||||||
{% if s.sub == current_user_sub %}<span class="muted">· you</span>{% endif %}
|
{% if s.sub == current_user_sub %}<span class="muted">· you</span>{% endif %}
|
||||||
</td>
|
</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(--bone);">{{ s.wins or 0 }}</td>
|
||||||
<td style="text-align: right; padding: 8px 0; font-family: var(--mono); color: var(--muted); font-size: .85em;">
|
<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 '—' }}
|
{{ s.last_win.strftime('%b %-d') if s.last_win else '—' }}
|
||||||
|
|
@ -86,7 +202,7 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">nobody's locked a week yet. be the first.</p>
|
<p class="muted">nobody's locked or generated yet. be the first.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
@ -101,6 +217,49 @@ async function lockPlan(btn) {
|
||||||
btn.disabled = false; btn.innerHTML = '🔒 lock this week';
|
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>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
85
tests/_testenv.py
Normal file
85
tests/_testenv.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""Test environment bootstrap.
|
||||||
|
|
||||||
|
Cauldron's server module instantiates a DB + OAuth client + Mealie client at
|
||||||
|
import time, so tests must stub those before importing cauldron.server.
|
||||||
|
|
||||||
|
This module performs that stubbing exactly once (idempotent — safe to import
|
||||||
|
multiple times). Both pytest's conftest.py and direct `unittest discover`
|
||||||
|
runs route through here.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
|
||||||
|
_BOOTSTRAPPED = False
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_env() -> None:
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-secret")
|
||||||
|
os.environ.setdefault("MEALIE_BASE_URL", "http://mealie.test")
|
||||||
|
os.environ.setdefault("MEALIE_API_TOKEN", "mealie-test-token")
|
||||||
|
os.environ.setdefault("CLAWDFORGE_URL", "http://forge.test")
|
||||||
|
os.environ.setdefault("CLAWDFORGE_TOKEN", "forge-test-token")
|
||||||
|
os.environ.setdefault("ADMIN_BEARER", "admin-test-bearer")
|
||||||
|
os.environ.setdefault("OIDC_ISSUER", "http://authentik.test/application/o/cauldron")
|
||||||
|
os.environ.setdefault("OIDC_CLIENT_ID", "test-client")
|
||||||
|
os.environ.setdefault("OIDC_CLIENT_SECRET", "test-secret-client")
|
||||||
|
os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost/auth/callback")
|
||||||
|
os.environ.setdefault("DB_HOST", "localhost")
|
||||||
|
os.environ.setdefault("DB_NAME", "cauldron_test")
|
||||||
|
os.environ.setdefault("DB_USER", "cauldron")
|
||||||
|
os.environ.setdefault("DB_PASSWORD", "test")
|
||||||
|
os.environ.setdefault("CAULDRON_FERNET_KEY", Fernet.generate_key().decode())
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeCursor:
|
||||||
|
def __init__(self):
|
||||||
|
self.lastrowid = 0
|
||||||
|
self.rowcount = 0
|
||||||
|
def execute(self, *a, **kw): pass
|
||||||
|
def fetchone(self): return None
|
||||||
|
def fetchall(self): return []
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeConn:
|
||||||
|
def __init__(self, *a, **kw): pass
|
||||||
|
def cursor(self): return _FakeCursor()
|
||||||
|
def commit(self): pass
|
||||||
|
def rollback(self): pass
|
||||||
|
def close(self): pass
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap() -> None:
|
||||||
|
"""Apply env + import-time monkey patches. Idempotent."""
|
||||||
|
global _BOOTSTRAPPED
|
||||||
|
if _BOOTSTRAPPED:
|
||||||
|
return
|
||||||
|
_stub_env()
|
||||||
|
|
||||||
|
# pymysql.connect → no-op fake
|
||||||
|
patch("pymysql.connect", _FakeConn).start()
|
||||||
|
|
||||||
|
# Pre-import dotted modules so the next patches resolve
|
||||||
|
import cauldron.db # noqa: F401
|
||||||
|
import cauldron.oidc # noqa: F401
|
||||||
|
import cauldron.foods # noqa: F401
|
||||||
|
|
||||||
|
# Skip migrations
|
||||||
|
patch("cauldron.db.DB.migrate", lambda self: []).start()
|
||||||
|
|
||||||
|
# Skip OIDC metadata fetch
|
||||||
|
oauth_obj = MagicMock()
|
||||||
|
oauth_obj.cauldron = MagicMock()
|
||||||
|
patch("cauldron.oidc.init_oauth", lambda *a, **kw: oauth_obj).start()
|
||||||
|
|
||||||
|
# Skip foods seed loader
|
||||||
|
patch("cauldron.foods.load_seed_if_empty", lambda db: 0).start()
|
||||||
|
|
||||||
|
_BOOTSTRAPPED = True
|
||||||
|
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Pytest auto-loaded conftest. Routes through _testenv so both pytest and
|
||||||
|
`unittest discover` see the same stubs."""
|
||||||
|
import os, sys
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
import _testenv # noqa: E402, F401
|
||||||
175
tests/test_list_view.py
Normal file
175
tests/test_list_view.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
"""Tests for the /list shopping-list view.
|
||||||
|
|
||||||
|
Two paths exercised:
|
||||||
|
1. plan with no slots → renders the "go to /plan" CTA
|
||||||
|
2. plan with slots + a (mocked) Mealie client returning canned recipes →
|
||||||
|
aggregator runs and the rendered HTML contains the expected lines
|
||||||
|
"""
|
||||||
|
import unittest
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# Trigger import-time stubs (env, pymysql, oidc) before pulling cauldron.server.
|
||||||
|
# Absolute import so unittest discover finds it.
|
||||||
|
import os, sys
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
import _testenv # noqa: E402, F401
|
||||||
|
|
||||||
|
from cauldron import server as srv
|
||||||
|
|
||||||
|
|
||||||
|
class _ListTestBase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = srv.app.test_client()
|
||||||
|
with self.client.session_transaction() as s:
|
||||||
|
s["user"] = {"sub": "sub-cobb", "email": "cobb@sulkta.com", "name": "Cobb"}
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_db(*, plan, slots=None):
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.get_user_household_id.return_value = 1
|
||||||
|
fake.get_or_create_plan.return_value = dict(plan)
|
||||||
|
fake.list_plan_slots.return_value = slots or []
|
||||||
|
fake.list_household_member_subs.return_value = ["sub-1"]
|
||||||
|
|
||||||
|
def _enrich(p):
|
||||||
|
p["slots"] = slots or []
|
||||||
|
return p
|
||||||
|
fake.enrich_plan_with_slots.side_effect = _enrich
|
||||||
|
|
||||||
|
# connection ctx for any auxiliary lookups (none needed here, but defensive)
|
||||||
|
from contextlib import contextmanager
|
||||||
|
@contextmanager
|
||||||
|
def _conn():
|
||||||
|
yield FakeConn()
|
||||||
|
fake.conn.side_effect = _conn
|
||||||
|
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def execute(self, *a, **kw): pass
|
||||||
|
def fetchone(self): return None
|
||||||
|
def fetchall(self): return []
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self): return FakeCursor()
|
||||||
|
def commit(self): pass
|
||||||
|
def rollback(self): pass
|
||||||
|
def close(self): pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestListNoPlan(_ListTestBase):
|
||||||
|
def test_list_renders_no_plan(self):
|
||||||
|
plan = {
|
||||||
|
"id": 99, "household_id": 1,
|
||||||
|
"week_start": date(2026, 4, 27),
|
||||||
|
"generated_by_sub": None, "generated_at": None,
|
||||||
|
"locked_by_sub": None, "locked_at": None, "locked_reason": None,
|
||||||
|
}
|
||||||
|
fake_db = _fake_db(plan=plan, slots=[])
|
||||||
|
with patch.object(srv, "db", fake_db):
|
||||||
|
r = self.client.get("/list")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
body = r.get_data(as_text=True).lower()
|
||||||
|
# CTA text
|
||||||
|
self.assertIn("no plan yet", body)
|
||||||
|
self.assertIn("/plan", body)
|
||||||
|
|
||||||
|
|
||||||
|
class TestListAggregated(_ListTestBase):
|
||||||
|
def test_list_renders_aggregated(self):
|
||||||
|
plan = {
|
||||||
|
"id": 100, "household_id": 1,
|
||||||
|
"week_start": date(2026, 4, 27),
|
||||||
|
"generated_by_sub": "sub-cobb",
|
||||||
|
"generated_at": None,
|
||||||
|
"locked_by_sub": None, "locked_at": None, "locked_reason": None,
|
||||||
|
}
|
||||||
|
slots = [
|
||||||
|
{"id": 1, "plan_id": 100, "day": "monday",
|
||||||
|
"recipe_slug": "stew", "recipe_name": "Stew",
|
||||||
|
"source": "mealie", "picker_subs": [], "reason": "",
|
||||||
|
"notes": None, "created_at": None},
|
||||||
|
{"id": 2, "plan_id": 100, "day": "tuesday",
|
||||||
|
"recipe_slug": "rice-bowl", "recipe_name": "Rice Bowl",
|
||||||
|
"source": "mealie", "picker_subs": [], "reason": "",
|
||||||
|
"notes": None, "created_at": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Canned Mealie recipes: stew has 1 lb rice, rice-bowl has 2 cup rice
|
||||||
|
# → density-mixed agg should produce a single rice line
|
||||||
|
fake_mealie = MagicMock()
|
||||||
|
def _get_recipe(slug):
|
||||||
|
if slug == "stew":
|
||||||
|
return {
|
||||||
|
"name": "Stew",
|
||||||
|
"recipeIngredient": [
|
||||||
|
{"quantity": 1, "unit": {"name": "lb"},
|
||||||
|
"food": {"name": "rice"}, "note": "",
|
||||||
|
"display": "1 lb rice"},
|
||||||
|
{"quantity": 2, "unit": {"name": "tbsp"},
|
||||||
|
"food": {"name": "olive oil"}, "note": "",
|
||||||
|
"display": "2 tbsp olive oil"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if slug == "rice-bowl":
|
||||||
|
return {
|
||||||
|
"name": "Rice Bowl",
|
||||||
|
"recipeIngredient": [
|
||||||
|
{"quantity": 2, "unit": {"name": "cup"},
|
||||||
|
"food": {"name": "rice"}, "note": "",
|
||||||
|
"display": "2 cups rice"},
|
||||||
|
{"quantity": 1, "unit": {"name": "cup"},
|
||||||
|
"food": {"name": "olive oil"}, "note": "",
|
||||||
|
"display": "1 cup olive oil"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
raise KeyError(slug)
|
||||||
|
fake_mealie.get_recipe.side_effect = _get_recipe
|
||||||
|
|
||||||
|
fake_db = _fake_db(plan=plan, slots=slots)
|
||||||
|
|
||||||
|
# foods.search_food returns canonical names + density for rice + olive oil
|
||||||
|
def _search_food(db, name, *, limit=1):
|
||||||
|
n = (name or "").strip().lower()
|
||||||
|
if "rice" in n:
|
||||||
|
return [{"id": 1, "canonical_name": "rice",
|
||||||
|
"density_g_per_ml": 0.85,
|
||||||
|
"default_unit_class": "mass",
|
||||||
|
"common_size_g": None, "category": "grain"}]
|
||||||
|
if "olive" in n or "oil" in n:
|
||||||
|
return [{"id": 2, "canonical_name": "olive oil",
|
||||||
|
"density_g_per_ml": 0.92,
|
||||||
|
"default_unit_class": "volume",
|
||||||
|
"common_size_g": None, "category": "oils"}]
|
||||||
|
return []
|
||||||
|
|
||||||
|
with patch.object(srv, "db", fake_db), \
|
||||||
|
patch.object(srv, "current_user_mealie", create=True), \
|
||||||
|
patch("cauldron.foods.search_food", side_effect=_search_food):
|
||||||
|
# patch the closure-bound current_user_mealie inside server.py
|
||||||
|
# via patching the helper Mealie constructor route
|
||||||
|
with patch("cauldron.server.Mealie", return_value=fake_mealie):
|
||||||
|
# Make the user-token blob path return a fake encrypted blob
|
||||||
|
fake_db.get_user_mealie_token_blob.return_value = b"fake-blob"
|
||||||
|
with patch.object(srv.crypto, "decrypt", return_value="t"):
|
||||||
|
r = self.client.get("/list")
|
||||||
|
|
||||||
|
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
||||||
|
body = r.get_data(as_text=True).lower()
|
||||||
|
# Rice should appear exactly once as an aggregated line; olive oil too
|
||||||
|
self.assertIn("rice", body)
|
||||||
|
self.assertIn("olive oil", body)
|
||||||
|
# Aggregated rice: 1 lb + 2 cup × 0.85 g/ml × 236.588 ml/cup
|
||||||
|
# = 453.6g + 402g = ~855g → display_mass picks lb (rounded .25)
|
||||||
|
self.assertIn("lb", body)
|
||||||
|
# localStorage data attribute should be present
|
||||||
|
self.assertIn('data-plan-id="100"', body)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
381
tests/test_plan_generator.py
Normal file
381
tests/test_plan_generator.py
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
"""Tests for the AI plan generator (forge.generate_plan + the
|
||||||
|
/api/plan/generate endpoint).
|
||||||
|
|
||||||
|
The endpoint tests use Flask's test client. db methods on the module-level
|
||||||
|
cauldron.server.db object are swapped out with MagicMocks per-test — this
|
||||||
|
avoids needing a real MariaDB to test routing + the orchestration logic.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
# Run conftest's import-time patches BEFORE pulling in cauldron.server.
|
||||||
|
# pytest auto-loads conftest, but unittest doesn't, so do it explicitly.
|
||||||
|
# Import path is absolute so `unittest discover` (which doesn't treat tests/
|
||||||
|
# as a package) and pytest both resolve it.
|
||||||
|
import os, sys
|
||||||
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
import _testenv # noqa: E402, F401
|
||||||
|
|
||||||
|
from cauldron import server as srv
|
||||||
|
from cauldron.forge import Forge, ForgeError
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- forge.generate_plan unit tests --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestForgeGeneratePlan(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.forge = Forge(
|
||||||
|
base_url="http://forge.test", token="t",
|
||||||
|
default_model="sonnet", default_timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ok_run(self, slots_payload):
|
||||||
|
"""Patch self.forge.run to return a dict shaped like clawdforge's."""
|
||||||
|
return patch.object(
|
||||||
|
self.forge, "run",
|
||||||
|
return_value={"result": {"slots": slots_payload}},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_validates_slot_count_matches(self):
|
||||||
|
recipes = [
|
||||||
|
{"slug": "r1", "name": "Stew"},
|
||||||
|
{"slug": "r2", "name": "Tacos"},
|
||||||
|
{"slug": "r3", "name": "Pasta"},
|
||||||
|
{"slug": "r4", "name": "Pie"},
|
||||||
|
{"slug": "r5", "name": "Curry"},
|
||||||
|
{"slug": "r6", "name": "Bowl"},
|
||||||
|
{"slug": "r7", "name": "Soup"},
|
||||||
|
]
|
||||||
|
# Model returns only 5 slots — must raise
|
||||||
|
bad = [{"day": d, "recipe_slug": "r1", "picker_subs": [], "reason": ""}
|
||||||
|
for d in ("monday", "tuesday", "wednesday", "thursday", "friday")]
|
||||||
|
with self._ok_run(bad):
|
||||||
|
with self.assertRaises(ForgeError) as cm:
|
||||||
|
self.forge.generate_plan(picks=[], recipes=recipes, slots=7, week_start="2026-04-27")
|
||||||
|
self.assertIn("expected 7", str(cm.exception))
|
||||||
|
|
||||||
|
def test_rejects_unknown_slug(self):
|
||||||
|
recipes = [{"slug": "r1", "name": "A"}]
|
||||||
|
bad = [{"day": "monday", "recipe_slug": "r-not-real", "picker_subs": [], "reason": ""}]
|
||||||
|
with self._ok_run(bad):
|
||||||
|
with self.assertRaises(ForgeError) as cm:
|
||||||
|
self.forge.generate_plan(picks=[], recipes=recipes, slots=1, week_start="2026-04-27")
|
||||||
|
self.assertIn("unknown recipe_slug", str(cm.exception))
|
||||||
|
|
||||||
|
def test_rejects_duplicate_day(self):
|
||||||
|
recipes = [{"slug": "r1", "name": "A"}, {"slug": "r2", "name": "B"}]
|
||||||
|
bad = [
|
||||||
|
{"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": ""},
|
||||||
|
{"day": "monday", "recipe_slug": "r2", "picker_subs": [], "reason": ""},
|
||||||
|
]
|
||||||
|
with self._ok_run(bad):
|
||||||
|
with self.assertRaises(ForgeError) as cm:
|
||||||
|
self.forge.generate_plan(picks=[], recipes=recipes, slots=2, week_start="2026-04-27")
|
||||||
|
self.assertIn("duplicate day", str(cm.exception))
|
||||||
|
|
||||||
|
def test_picker_attribution_uses_real_subs(self):
|
||||||
|
"""Even if the model omits picker_subs, our ground-truth pick map
|
||||||
|
is what ends up on the slot."""
|
||||||
|
recipes = [{"slug": "r1", "name": "Stew"}]
|
||||||
|
picks = [{"slug": "r1", "name": "Stew", "picker_subs": ["sub-abby", "sub-cobb"]}]
|
||||||
|
# Model returns empty picker_subs — we should fill from the picks
|
||||||
|
slots_in = [{"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": "honors picks"}]
|
||||||
|
with self._ok_run(slots_in):
|
||||||
|
out = self.forge.generate_plan(
|
||||||
|
picks=picks, recipes=recipes, slots=1, week_start="2026-04-27",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(out), 1)
|
||||||
|
self.assertEqual(out[0]["picker_subs"], ["sub-abby", "sub-cobb"])
|
||||||
|
self.assertEqual(out[0]["source"], "pick")
|
||||||
|
self.assertEqual(out[0]["recipe_name"], "Stew")
|
||||||
|
self.assertEqual(out[0]["reason"], "honors picks")
|
||||||
|
|
||||||
|
def test_string_response_is_parsed(self):
|
||||||
|
"""clawdforge sometimes returns the JSON as a string in `result`."""
|
||||||
|
recipes = [{"slug": "r1", "name": "A"}]
|
||||||
|
payload = {"slots": [{"day": "monday", "recipe_slug": "r1",
|
||||||
|
"picker_subs": [], "reason": "ai"}]}
|
||||||
|
with patch.object(self.forge, "run",
|
||||||
|
return_value={"result": json.dumps(payload)}):
|
||||||
|
out = self.forge.generate_plan(
|
||||||
|
picks=[], recipes=recipes, slots=1, week_start="2026-04-27",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(out), 1)
|
||||||
|
self.assertEqual(out[0]["recipe_slug"], "r1")
|
||||||
|
self.assertEqual(out[0]["source"], "mealie") # no picks → mealie source
|
||||||
|
|
||||||
|
def test_code_fenced_response_is_parsed(self):
|
||||||
|
recipes = [{"slug": "r1", "name": "A"}]
|
||||||
|
payload = {"slots": [{"day": "monday", "recipe_slug": "r1",
|
||||||
|
"picker_subs": [], "reason": ""}]}
|
||||||
|
fenced = "```json\n" + json.dumps(payload) + "\n```"
|
||||||
|
with patch.object(self.forge, "run", return_value={"result": fenced}):
|
||||||
|
out = self.forge.generate_plan(
|
||||||
|
picks=[], recipes=recipes, slots=1, week_start="2026-04-27",
|
||||||
|
)
|
||||||
|
self.assertEqual(out[0]["recipe_slug"], "r1")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- /api/plan/generate route tests --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db_stub(*, plan, picks=None, recipe_rows=None,
|
||||||
|
existing_slots=None, save_inserted=None):
|
||||||
|
"""Build a fake db with the methods the route uses."""
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.list_household_picks_with_pickers.return_value = picks or []
|
||||||
|
fake.list_indexed_recipes.return_value = recipe_rows or []
|
||||||
|
fake.list_plan_slots.return_value = existing_slots or []
|
||||||
|
fake.get_or_create_plan.return_value = dict(plan)
|
||||||
|
fake.auto_lock_past_unlocked_plans.return_value = 0
|
||||||
|
fake.list_household_member_subs.return_value = ["sub-1"]
|
||||||
|
fake.get_user_household_id.return_value = 1
|
||||||
|
fake.list_household_pick_slugs.return_value = set()
|
||||||
|
fake.household_scoreboard.return_value = []
|
||||||
|
fake.household_streak.return_value = None
|
||||||
|
fake.upsert_user.return_value = None
|
||||||
|
|
||||||
|
# save_plan_slots returns inserted count (1+ default, or override for race tests)
|
||||||
|
if save_inserted is None:
|
||||||
|
save_inserted = lambda plan_id, slots: len(slots)
|
||||||
|
fake.save_plan_slots.side_effect = save_inserted
|
||||||
|
|
||||||
|
# mark_plan_generated returns updated plan dict
|
||||||
|
def _mark(plan_id, sub):
|
||||||
|
p = dict(plan)
|
||||||
|
p["generated_by_sub"] = sub
|
||||||
|
from datetime import datetime
|
||||||
|
p["generated_at"] = datetime(2026, 4, 27, 12, 0, 0)
|
||||||
|
return p
|
||||||
|
fake.mark_plan_generated.side_effect = _mark
|
||||||
|
|
||||||
|
# enrich_plan_with_slots adds slots to the plan dict in-place
|
||||||
|
def _enrich(p):
|
||||||
|
p["slots"] = fake.list_plan_slots.return_value
|
||||||
|
return p
|
||||||
|
fake.enrich_plan_with_slots.side_effect = _enrich
|
||||||
|
|
||||||
|
# conn() context manager stub for the display-name resolution
|
||||||
|
from contextlib import contextmanager
|
||||||
|
@contextmanager
|
||||||
|
def _conn():
|
||||||
|
yield FakeConn()
|
||||||
|
fake.conn.side_effect = _conn
|
||||||
|
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCursor:
|
||||||
|
def __init__(self):
|
||||||
|
self._rows = []
|
||||||
|
def execute(self, *a, **kw): pass
|
||||||
|
def fetchone(self): return None
|
||||||
|
def fetchall(self): return []
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
def cursor(self): return FakeCursor()
|
||||||
|
def commit(self): pass
|
||||||
|
def rollback(self): pass
|
||||||
|
def close(self): pass
|
||||||
|
|
||||||
|
|
||||||
|
class _RouteTestBase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = srv.app.test_client()
|
||||||
|
# Inject a session via a context override
|
||||||
|
with self.client.session_transaction() as s:
|
||||||
|
s["user"] = {"sub": "sub-cobb", "email": "cobb@sulkta.com", "name": "Cobb"}
|
||||||
|
|
||||||
|
def _patch_db(self, fake_db):
|
||||||
|
return patch.object(srv, "db", fake_db)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerateRoute(_RouteTestBase):
|
||||||
|
def test_generate_creates_slots(self):
|
||||||
|
from datetime import date
|
||||||
|
plan = {
|
||||||
|
"id": 42, "household_id": 1,
|
||||||
|
"week_start": date(2026, 4, 27),
|
||||||
|
"generated_by_sub": None, "generated_at": None,
|
||||||
|
"locked_by_sub": None, "locked_at": None, "locked_reason": None,
|
||||||
|
}
|
||||||
|
recipe_rows = [
|
||||||
|
{"slug": f"r{i}", "name": f"Recipe {i}", "raw_json": "{}"}
|
||||||
|
for i in range(1, 11)
|
||||||
|
]
|
||||||
|
slots_returned = [
|
||||||
|
{"day": d, "recipe_slug": "r1", "recipe_name": "Recipe 1",
|
||||||
|
"picker_subs": [], "reason": "ai", "source": "mealie"}
|
||||||
|
for d in ("monday", "tuesday", "wednesday", "thursday",
|
||||||
|
"friday", "saturday", "sunday")
|
||||||
|
]
|
||||||
|
# Make slots use unique recipes per day for realism
|
||||||
|
for i, s in enumerate(slots_returned):
|
||||||
|
s["recipe_slug"] = f"r{i+1}"
|
||||||
|
s["recipe_name"] = f"Recipe {i+1}"
|
||||||
|
|
||||||
|
fake_db = _make_db_stub(plan=plan, recipe_rows=recipe_rows)
|
||||||
|
with self._patch_db(fake_db), \
|
||||||
|
patch.object(srv.forge, "generate_plan", return_value=slots_returned):
|
||||||
|
r = self.client.post("/api/plan/generate")
|
||||||
|
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
||||||
|
body = r.get_json()
|
||||||
|
self.assertTrue(body["ok"])
|
||||||
|
# save_plan_slots called with the plan id and the slots list
|
||||||
|
fake_db.save_plan_slots.assert_called_once()
|
||||||
|
args, _ = fake_db.save_plan_slots.call_args
|
||||||
|
self.assertEqual(args[0], 42)
|
||||||
|
self.assertEqual(len(args[1]), 7)
|
||||||
|
self.assertEqual(args[1][0]["day"], "monday")
|
||||||
|
# mark_plan_generated called with cobb's sub
|
||||||
|
fake_db.mark_plan_generated.assert_called_once_with(42, "sub-cobb")
|
||||||
|
|
||||||
|
def test_generate_when_locked_409(self):
|
||||||
|
from datetime import date, datetime
|
||||||
|
plan = {
|
||||||
|
"id": 7, "household_id": 1, "week_start": date(2026, 4, 27),
|
||||||
|
"generated_by_sub": None, "generated_at": None,
|
||||||
|
"locked_by_sub": "sub-abby",
|
||||||
|
"locked_at": datetime(2026, 4, 27, 18, 0),
|
||||||
|
"locked_reason": "user",
|
||||||
|
}
|
||||||
|
fake_db = _make_db_stub(plan=plan)
|
||||||
|
with self._patch_db(fake_db), \
|
||||||
|
patch.object(srv.forge, "generate_plan") as gp:
|
||||||
|
r = self.client.post("/api/plan/generate")
|
||||||
|
self.assertEqual(r.status_code, 409)
|
||||||
|
self.assertEqual(r.get_json()["error"], "plan_locked")
|
||||||
|
gp.assert_not_called()
|
||||||
|
|
||||||
|
def test_generate_when_already_generated_409(self):
|
||||||
|
from datetime import date
|
||||||
|
plan = {
|
||||||
|
"id": 9, "household_id": 1, "week_start": date(2026, 4, 27),
|
||||||
|
"generated_by_sub": "sub-abby", "generated_at": None,
|
||||||
|
"locked_by_sub": None, "locked_at": None, "locked_reason": None,
|
||||||
|
}
|
||||||
|
existing = [{
|
||||||
|
"id": 1, "plan_id": 9, "day": "monday",
|
||||||
|
"recipe_slug": "r1", "recipe_name": "Stew",
|
||||||
|
"source": "mealie", "picker_subs": [], "reason": "", "notes": None,
|
||||||
|
"created_at": None,
|
||||||
|
}]
|
||||||
|
fake_db = _make_db_stub(plan=plan, existing_slots=existing)
|
||||||
|
with self._patch_db(fake_db), \
|
||||||
|
patch.object(srv.forge, "generate_plan") as gp:
|
||||||
|
r = self.client.post("/api/plan/generate")
|
||||||
|
self.assertEqual(r.status_code, 409)
|
||||||
|
body = r.get_json()
|
||||||
|
self.assertEqual(body["error"], "plan_already_generated")
|
||||||
|
self.assertIn("plan", body)
|
||||||
|
self.assertEqual(len(body["plan"]["slots"]), 1)
|
||||||
|
gp.assert_not_called()
|
||||||
|
|
||||||
|
def test_pick_points_awarded_on_pick_use(self):
|
||||||
|
from datetime import date
|
||||||
|
plan = {
|
||||||
|
"id": 11, "household_id": 1, "week_start": date(2026, 4, 27),
|
||||||
|
"generated_by_sub": None, "generated_at": None,
|
||||||
|
"locked_by_sub": None, "locked_at": None, "locked_reason": None,
|
||||||
|
}
|
||||||
|
recipe_rows = [
|
||||||
|
{"slug": "stew", "name": "Stew", "raw_json": "{}"},
|
||||||
|
{"slug": "tacos", "name": "Tacos", "raw_json": "{}"},
|
||||||
|
]
|
||||||
|
picks = [
|
||||||
|
{"slug": "stew", "name": "Stew",
|
||||||
|
"pickers": ["abby"], "picker_subs": ["sub-abby"]},
|
||||||
|
{"slug": "tacos", "name": "Tacos",
|
||||||
|
"pickers": ["cobb", "abby"], "picker_subs": ["sub-cobb", "sub-abby"]},
|
||||||
|
]
|
||||||
|
# Slot fixture: monday = stew (abby picks), tuesday = tacos (cobb +
|
||||||
|
# abby picks), wed-sun = stew (ai-chosen, no pickers).
|
||||||
|
slots_full = []
|
||||||
|
days = ("monday", "tuesday", "wednesday", "thursday",
|
||||||
|
"friday", "saturday", "sunday")
|
||||||
|
for i, d in enumerate(days):
|
||||||
|
if i == 0:
|
||||||
|
slots_full.append({
|
||||||
|
"day": d, "recipe_slug": "stew", "recipe_name": "Stew",
|
||||||
|
"picker_subs": ["sub-abby"], "reason": "abby's pick",
|
||||||
|
"source": "pick",
|
||||||
|
})
|
||||||
|
elif i == 1:
|
||||||
|
slots_full.append({
|
||||||
|
"day": d, "recipe_slug": "tacos", "recipe_name": "Tacos",
|
||||||
|
"picker_subs": ["sub-cobb", "sub-abby"], "reason": "co",
|
||||||
|
"source": "pick",
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
slots_full.append({
|
||||||
|
"day": d, "recipe_slug": "stew", "recipe_name": "Stew",
|
||||||
|
"picker_subs": [], "reason": "ai", "source": "mealie",
|
||||||
|
})
|
||||||
|
|
||||||
|
fake_db = _make_db_stub(plan=plan, picks=picks, recipe_rows=recipe_rows)
|
||||||
|
with self._patch_db(fake_db), \
|
||||||
|
patch.object(srv.forge, "generate_plan", return_value=slots_full):
|
||||||
|
r = self.client.post("/api/plan/generate")
|
||||||
|
self.assertEqual(r.status_code, 200, r.get_data(as_text=True))
|
||||||
|
|
||||||
|
# 1pt for sub-abby on monday + 1pt sub-cobb + 1pt sub-abby on tuesday
|
||||||
|
# = 3 award_pick_points calls total
|
||||||
|
self.assertEqual(fake_db.award_pick_points.call_count, 3)
|
||||||
|
# All calls should be (1, 11, <sub>, 1, "pick_used")
|
||||||
|
called_subs = [c.args[2] for c in fake_db.award_pick_points.call_args_list]
|
||||||
|
self.assertEqual(sorted(called_subs), ["sub-abby", "sub-abby", "sub-cobb"])
|
||||||
|
for call in fake_db.award_pick_points.call_args_list:
|
||||||
|
self.assertEqual(call.args[3], 1) # points
|
||||||
|
self.assertEqual(call.args[4], "pick_used")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- household_scoreboard SQL test --------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoreboardSchema(unittest.TestCase):
|
||||||
|
"""The scoreboard SELECT must reference cauldron_pick_points and return
|
||||||
|
a `points` field. Verified by inspecting the generated SQL via a
|
||||||
|
capturing fake cursor."""
|
||||||
|
|
||||||
|
def test_scoreboard_query_includes_points(self):
|
||||||
|
from cauldron.db import DB
|
||||||
|
captured = {"sql": None}
|
||||||
|
|
||||||
|
class CapCursor:
|
||||||
|
def execute(self, sql, params=None):
|
||||||
|
captured["sql"] = sql
|
||||||
|
def fetchall(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"sub": "sub-cobb", "email": "cobb@x.com",
|
||||||
|
"display_name": "Cobb",
|
||||||
|
"wins": 2, "last_win": None, "points": 5,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *a): return False
|
||||||
|
|
||||||
|
class CapConn:
|
||||||
|
def cursor(self): return CapCursor()
|
||||||
|
def commit(self): pass
|
||||||
|
def rollback(self): pass
|
||||||
|
def close(self): pass
|
||||||
|
|
||||||
|
db = DB(host="x", port=3306, name="x", user="x", password="x")
|
||||||
|
with patch("pymysql.connect", lambda **kw: CapConn()):
|
||||||
|
rows = db.household_scoreboard(1)
|
||||||
|
|
||||||
|
self.assertIn("cauldron_pick_points", captured["sql"])
|
||||||
|
self.assertIn("points", captured["sql"])
|
||||||
|
# And the row decoder coerces points to int + adds weeks_locked alias
|
||||||
|
self.assertEqual(rows[0]["points"], 5)
|
||||||
|
self.assertEqual(rows[0]["weeks_locked"], 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue