v0.2: household-shared picks pool + /plan with lock button + scoreboard + streak
DB: - migration 006 — cauldron_households (mirrors Mealie household), members - migration 007 — cauldron_meal_plans (per household per week, lock state) - new helpers: upsert_household, add_household_member, get_user_household_id, list_household_member_subs, get_or_create_plan, lock_plan, auto_lock_past_unlocked_plans, household_scoreboard, household_streak, list_household_pick_slugs, list_household_picks_with_pickers Backend: - sync_user_household() — pulls Mealie's /api/users/self, upserts the household row, ensures membership. Fires on /connect-mealie POST and lazy on every /me / /picks / /plan / /recipes load. - current_household_id() helper used by all user-facing routes - /recipes + /api/recipes.json now mark items.picked=True if ANYONE in the household has pinned (shared pool, not per-user) - /picks now renders household-pooled view with attribution (who pinned what) - /plan replaces stub: shows current week's lock state + lock button + scoreboard + streak. POST /api/plan/lock locks this week's plan with reason='user'. Past unlocked weeks auto-lock on read with reason='auto'. - /list still stub (v0.3 territory) UI: - picks.html: each pick shows '🍄 pinned by <name(s)>' attribution row, unpin button only on your own picks, household_size in lede - plan.html: NEW. Lock state pill, lock button (open weeks only), scoreboard table with rank/wins/last_win, streak ribbon ('🔥 bob on a 3-week run') if streak >= 2 - me.html: shows household name + member count Locking semantics match Alice's pick: - (c) both — user-lock = scoreboard win, auto-lock past calendar = no win. Auto-lock fires lazily on /plan view (no cron needed for v0.2). - Picks pool = (a) shared across household. - Game shape = medium (locked-by + tally + streak counter, room for richer badges/notifs in v0.4).
This commit is contained in:
parent
578dda32e5
commit
6539bd4049
5 changed files with 489 additions and 16 deletions
241
cauldron/db.py
241
cauldron/db.py
|
|
@ -71,6 +71,45 @@ MIGRATIONS = [
|
||||||
INDEX idx_user_added (authentik_sub, added_at)
|
INDEX idx_user_added (authentik_sub, added_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
""",
|
""",
|
||||||
|
# 006 — households (cached mirror of Mealie's household) + membership.
|
||||||
|
# Keyed by Mealie's UUID. Multiple cauldron users join via the same
|
||||||
|
# Mealie household to share picks/plans.
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS cauldron_households (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
mealie_household_id VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS cauldron_household_members (
|
||||||
|
household_id BIGINT NOT NULL,
|
||||||
|
authentik_sub VARCHAR(190) NOT NULL,
|
||||||
|
role VARCHAR(32) NOT NULL DEFAULT 'member',
|
||||||
|
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (household_id, authentik_sub),
|
||||||
|
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (authentik_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""",
|
||||||
|
# 007 — meal plans (per household per week). Lock state + race metadata.
|
||||||
|
# week_start = Monday (date) of the week.
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS cauldron_meal_plans (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
household_id BIGINT NOT NULL,
|
||||||
|
week_start DATE NOT NULL,
|
||||||
|
generated_by_sub VARCHAR(190),
|
||||||
|
generated_at DATETIME,
|
||||||
|
locked_by_sub VARCHAR(190),
|
||||||
|
locked_at DATETIME,
|
||||||
|
locked_reason ENUM('user','auto') DEFAULT NULL,
|
||||||
|
UNIQUE KEY uk_household_week (household_id, week_start),
|
||||||
|
INDEX idx_locked_by (locked_by_sub),
|
||||||
|
FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -188,6 +227,154 @@ class DB:
|
||||||
(reason[:500], sub),
|
(reason[:500], sub),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- households ---------------------------------------------------------
|
||||||
|
|
||||||
|
def upsert_household(self, *, mealie_household_id: str, name: str) -> int:
|
||||||
|
"""Create or update a household record. Returns local PK (id)."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO cauldron_households (mealie_household_id, name)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name), id = LAST_INSERT_ID(id)
|
||||||
|
""",
|
||||||
|
(mealie_household_id, name),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
def add_household_member(self, household_id: int, sub: str, role: str = "member") -> None:
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT IGNORE INTO cauldron_household_members
|
||||||
|
(household_id, authentik_sub, role)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""",
|
||||||
|
(household_id, sub, role),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_user_household_id(self, sub: str) -> int | None:
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT household_id FROM cauldron_household_members WHERE authentik_sub=%s LIMIT 1",
|
||||||
|
(sub,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row["household_id"] if row else None
|
||||||
|
|
||||||
|
def list_household_member_subs(self, household_id: int) -> list[str]:
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT authentik_sub FROM cauldron_household_members WHERE household_id=%s",
|
||||||
|
(household_id,),
|
||||||
|
)
|
||||||
|
return [r["authentik_sub"] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# --- meal plans (per household per week) -------------------------------
|
||||||
|
|
||||||
|
def get_or_create_plan(self, household_id: int, week_start) -> dict:
|
||||||
|
"""Get the plan record for a (household, week_start), creating an
|
||||||
|
empty one if it doesn't exist. week_start is a date (Monday)."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT IGNORE INTO cauldron_meal_plans (household_id, week_start)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""",
|
||||||
|
(household_id, week_start),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT * FROM cauldron_meal_plans WHERE household_id=%s AND week_start=%s",
|
||||||
|
(household_id, week_start),
|
||||||
|
)
|
||||||
|
return dict(cur.fetchone())
|
||||||
|
|
||||||
|
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:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cauldron_meal_plans
|
||||||
|
SET locked_by_sub = %s, locked_at = NOW(), locked_reason = %s
|
||||||
|
WHERE id = %s AND locked_at IS NULL
|
||||||
|
""",
|
||||||
|
(sub, reason, plan_id),
|
||||||
|
)
|
||||||
|
cur.execute("SELECT * FROM cauldron_meal_plans WHERE id=%s", (plan_id,))
|
||||||
|
return dict(cur.fetchone())
|
||||||
|
|
||||||
|
def auto_lock_past_unlocked_plans(self, household_id: int, before_date) -> int:
|
||||||
|
"""Mark any past unlocked plans as auto-locked. Returns count."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE cauldron_meal_plans
|
||||||
|
SET locked_at = NOW(), locked_reason = 'auto'
|
||||||
|
WHERE household_id = %s AND week_start < %s AND locked_at IS NULL
|
||||||
|
""",
|
||||||
|
(household_id, before_date),
|
||||||
|
)
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
def household_scoreboard(self, household_id: int) -> list[dict]:
|
||||||
|
"""Per-user lock counts + most recent lock time. Joins to users for
|
||||||
|
display name. Excludes auto-locks (those are no-one's win)."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.authentik_sub AS sub,
|
||||||
|
u.email AS email,
|
||||||
|
u.display_name AS display_name,
|
||||||
|
COUNT(p.id) AS wins,
|
||||||
|
MAX(p.locked_at) AS last_win
|
||||||
|
FROM cauldron_household_members m
|
||||||
|
LEFT JOIN cauldron_users u
|
||||||
|
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
|
||||||
|
GROUP BY u.authentik_sub, u.email, u.display_name
|
||||||
|
ORDER BY wins DESC, last_win DESC
|
||||||
|
""",
|
||||||
|
(household_id,),
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
def household_streak(self, household_id: int) -> dict | None:
|
||||||
|
"""Compute current win streak: walk back from most recent locked week,
|
||||||
|
counting consecutive weeks won by the same user. Returns
|
||||||
|
{sub, display_name, count} or None if no locks."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT p.week_start, p.locked_by_sub, u.display_name, u.email
|
||||||
|
FROM cauldron_meal_plans p
|
||||||
|
LEFT JOIN cauldron_users u ON u.authentik_sub = p.locked_by_sub
|
||||||
|
WHERE p.household_id = %s
|
||||||
|
AND p.locked_at IS NOT NULL
|
||||||
|
AND p.locked_reason = 'user'
|
||||||
|
ORDER BY p.week_start DESC
|
||||||
|
""",
|
||||||
|
(household_id,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
leader = rows[0]["locked_by_sub"]
|
||||||
|
count = 0
|
||||||
|
for r in rows:
|
||||||
|
if r["locked_by_sub"] != leader:
|
||||||
|
break
|
||||||
|
count += 1
|
||||||
|
return {
|
||||||
|
"sub": leader,
|
||||||
|
"display_name": rows[0]["display_name"] or rows[0]["email"],
|
||||||
|
"count": count,
|
||||||
|
}
|
||||||
|
|
||||||
# --- 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:
|
||||||
|
|
@ -226,6 +413,60 @@ class DB:
|
||||||
)
|
)
|
||||||
return {r["recipe_slug"] for r in cur.fetchall()}
|
return {r["recipe_slug"] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
def list_household_pick_slugs(self, household_id: int) -> set[str]:
|
||||||
|
"""Union of picks across all members of the household."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT p.recipe_slug
|
||||||
|
FROM cauldron_meal_picks p
|
||||||
|
JOIN cauldron_household_members m ON m.authentik_sub = p.authentik_sub
|
||||||
|
WHERE m.household_id = %s
|
||||||
|
""",
|
||||||
|
(household_id,),
|
||||||
|
)
|
||||||
|
return {r["recipe_slug"] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
def list_household_picks_with_pickers(self, household_id: int) -> list[dict]:
|
||||||
|
"""All picks across the household, grouped by slug, with the list of
|
||||||
|
members who picked each (so the UI can show 'pinned by Alice · Bob').
|
||||||
|
Latest pick added_at per slug for ordering."""
|
||||||
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.recipe_slug AS slug,
|
||||||
|
MIN(p.recipe_name) AS name,
|
||||||
|
GROUP_CONCAT(
|
||||||
|
DISTINCT COALESCE(NULLIF(u.display_name, ''),
|
||||||
|
SUBSTRING_INDEX(u.email, '@', 1))
|
||||||
|
ORDER BY p.added_at ASC
|
||||||
|
SEPARATOR '|'
|
||||||
|
) AS pickers,
|
||||||
|
GROUP_CONCAT(
|
||||||
|
DISTINCT u.authentik_sub
|
||||||
|
ORDER BY p.added_at ASC
|
||||||
|
SEPARATOR '|'
|
||||||
|
) AS picker_subs,
|
||||||
|
MAX(p.added_at) AS last_pick_at,
|
||||||
|
COUNT(*) AS pick_count
|
||||||
|
FROM cauldron_meal_picks p
|
||||||
|
JOIN cauldron_household_members m ON m.authentik_sub = p.authentik_sub
|
||||||
|
LEFT JOIN cauldron_users u ON u.authentik_sub = p.authentik_sub
|
||||||
|
WHERE m.household_id = %s
|
||||||
|
GROUP BY p.recipe_slug
|
||||||
|
ORDER BY last_pick_at DESC
|
||||||
|
""",
|
||||||
|
(household_id,),
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
d = dict(r)
|
||||||
|
d["pickers"] = (d["pickers"] or "").split("|") if d["pickers"] else []
|
||||||
|
d["picker_subs"] = (d["picker_subs"] or "").split("|") if d["picker_subs"] else []
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
# --- chat log -----------------------------------------------------------
|
# --- chat log -----------------------------------------------------------
|
||||||
|
|
||||||
def log_chat(
|
def log_chat(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ Routes (current):
|
||||||
POST /api/sterilize/preview/<slug> (admin bearer) v0.1 sterilizer
|
POST /api/sterilize/preview/<slug> (admin bearer) v0.1 sterilizer
|
||||||
POST /api/sterilize/apply/<slug> (admin bearer) v0.1 sterilizer
|
POST /api/sterilize/apply/<slug> (admin bearer) v0.1 sterilizer
|
||||||
"""
|
"""
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
|
@ -105,6 +106,40 @@ def create_app() -> Flask:
|
||||||
return None
|
return None
|
||||||
return Mealie(base_url=cfg.mealie_api_url, api_token=tok)
|
return Mealie(base_url=cfg.mealie_api_url, api_token=tok)
|
||||||
|
|
||||||
|
def sync_user_household(sub: str) -> int | None:
|
||||||
|
"""Pull the user's Mealie household, upsert into cauldron, ensure
|
||||||
|
membership. Idempotent. Returns local household_id or None."""
|
||||||
|
client = current_user_mealie()
|
||||||
|
if not client:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
me = client.who_am_i()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
h = me.get("household") or {}
|
||||||
|
h_id_mealie = h.get("id")
|
||||||
|
h_name = h.get("name") or h.get("slug") or "default"
|
||||||
|
if not h_id_mealie:
|
||||||
|
return None
|
||||||
|
local_id = db.upsert_household(mealie_household_id=str(h_id_mealie), name=h_name)
|
||||||
|
# First member of a household becomes admin; rest stay 'member'
|
||||||
|
existing = db.list_household_member_subs(local_id)
|
||||||
|
role = "member" if existing else "admin"
|
||||||
|
db.add_household_member(local_id, sub, role=role if sub not in existing else "member")
|
||||||
|
return local_id
|
||||||
|
|
||||||
|
def current_household_id() -> int | None:
|
||||||
|
u = session.get("user")
|
||||||
|
if not u:
|
||||||
|
return None
|
||||||
|
h = db.get_user_household_id(u["sub"])
|
||||||
|
if h is None:
|
||||||
|
h = sync_user_household(u["sub"])
|
||||||
|
return h
|
||||||
|
|
||||||
|
def monday_of(d: date) -> date:
|
||||||
|
return d - timedelta(days=d.weekday())
|
||||||
|
|
||||||
# ---------- public ---------------------------------------------------
|
# ---------- public ---------------------------------------------------
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
|
|
@ -161,6 +196,7 @@ def create_app() -> Flask:
|
||||||
u = session["user"]
|
u = session["user"]
|
||||||
connected = db.get_user_mealie_token_blob(u["sub"]) is not None
|
connected = db.get_user_mealie_token_blob(u["sub"]) is not None
|
||||||
mealie_user = None
|
mealie_user = None
|
||||||
|
household_size = 0
|
||||||
if connected:
|
if connected:
|
||||||
client = current_user_mealie()
|
client = current_user_mealie()
|
||||||
if client:
|
if client:
|
||||||
|
|
@ -168,8 +204,14 @@ def create_app() -> Flask:
|
||||||
mealie_user = client.who_am_i()
|
mealie_user = client.who_am_i()
|
||||||
except Exception:
|
except Exception:
|
||||||
mealie_user = None
|
mealie_user = None
|
||||||
|
# Lazy-sync household if not done yet
|
||||||
|
hid = current_household_id()
|
||||||
|
if hid:
|
||||||
|
household_size = len(db.list_household_member_subs(hid))
|
||||||
return render_template(
|
return render_template(
|
||||||
"me.html", user=u, connected=connected, mealie_user=mealie_user, active="me"
|
"me.html",
|
||||||
|
user=u, connected=connected, mealie_user=mealie_user,
|
||||||
|
household_size=household_size, active="me",
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.get("/me.json")
|
@app.get("/me.json")
|
||||||
|
|
@ -209,6 +251,9 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
blob = crypto.encrypt(token)
|
blob = crypto.encrypt(token)
|
||||||
db.set_user_mealie_token_blob(u["sub"], blob)
|
db.set_user_mealie_token_blob(u["sub"], blob)
|
||||||
|
# Sync household membership so the user immediately joins the shared
|
||||||
|
# picks pool + scoreboard (no manual admin step needed for first member).
|
||||||
|
sync_user_household(u["sub"])
|
||||||
return redirect(url_for("me"))
|
return redirect(url_for("me"))
|
||||||
|
|
||||||
@app.post("/disconnect-mealie")
|
@app.post("/disconnect-mealie")
|
||||||
|
|
@ -234,7 +279,9 @@ def create_app() -> Flask:
|
||||||
items = data.get("items", []) or []
|
items = data.get("items", []) or []
|
||||||
total = data.get("total", len(items))
|
total = data.get("total", len(items))
|
||||||
pages = data.get("total_pages", 1) or 1
|
pages = data.get("total_pages", 1) or 1
|
||||||
pick_slugs = db.list_meal_pick_slugs(u["sub"])
|
# Picked is true if ANYONE in the household pinned it (shared pool)
|
||||||
|
hid = current_household_id()
|
||||||
|
pick_slugs = db.list_household_pick_slugs(hid) if hid else set()
|
||||||
for it in items:
|
for it in items:
|
||||||
it["picked"] = it.get("slug") in pick_slugs
|
it["picked"] = it.get("slug") in pick_slugs
|
||||||
return render_template(
|
return render_template(
|
||||||
|
|
@ -256,7 +303,8 @@ def create_app() -> Flask:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 502
|
return jsonify({"error": str(e)}), 502
|
||||||
items = data.get("items", []) or []
|
items = data.get("items", []) or []
|
||||||
pick_slugs = db.list_meal_pick_slugs(u["sub"])
|
hid = current_household_id()
|
||||||
|
pick_slugs = db.list_household_pick_slugs(hid) if hid else set()
|
||||||
for it in items:
|
for it in items:
|
||||||
it["picked"] = it.get("slug") in pick_slugs
|
it["picked"] = it.get("slug") in pick_slugs
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|
@ -303,13 +351,74 @@ def create_app() -> Flask:
|
||||||
@require_session
|
@require_session
|
||||||
def picks_view():
|
def picks_view():
|
||||||
u = session["user"]
|
u = session["user"]
|
||||||
picks = db.list_meal_picks(u["sub"])
|
hid = current_household_id()
|
||||||
return render_template("picks.html", picks=picks, active="picks")
|
picks = db.list_household_picks_with_pickers(hid) if hid else []
|
||||||
|
my_picks = db.list_meal_pick_slugs(u["sub"])
|
||||||
|
for p in picks:
|
||||||
|
p["mine"] = p["slug"] in my_picks
|
||||||
|
return render_template(
|
||||||
|
"picks.html",
|
||||||
|
picks=picks,
|
||||||
|
active="picks",
|
||||||
|
household_size=len(db.list_household_member_subs(hid)) if hid else 0,
|
||||||
|
)
|
||||||
|
|
||||||
@app.get("/plan")
|
@app.get("/plan")
|
||||||
@require_session
|
@require_session
|
||||||
def plan_view():
|
def plan_view():
|
||||||
return render_template("stub.html", title="plan", coming="weekly meal plan generator", active="plan")
|
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)
|
||||||
|
# Auto-lock any past unlocked weeks before reading
|
||||||
|
db.auto_lock_past_unlocked_plans(hid, this_monday)
|
||||||
|
|
||||||
|
plan = db.get_or_create_plan(hid, this_monday)
|
||||||
|
scoreboard = db.household_scoreboard(hid)
|
||||||
|
streak = db.household_streak(hid)
|
||||||
|
pick_count = len(db.list_household_pick_slugs(hid))
|
||||||
|
|
||||||
|
# Look up display name for locked_by
|
||||||
|
locked_by_display = None
|
||||||
|
if plan.get("locked_by_sub"):
|
||||||
|
with db.conn() as c, c.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT display_name, email FROM cauldron_users WHERE authentik_sub=%s",
|
||||||
|
(plan["locked_by_sub"],),
|
||||||
|
)
|
||||||
|
r = cur.fetchone()
|
||||||
|
if r:
|
||||||
|
locked_by_display = r["display_name"] or (r["email"] or "").split("@")[0]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"plan.html",
|
||||||
|
week_start=plan["week_start"],
|
||||||
|
plan=plan,
|
||||||
|
locked_by_display=locked_by_display,
|
||||||
|
scoreboard=scoreboard,
|
||||||
|
streak=streak,
|
||||||
|
current_user_sub=u["sub"],
|
||||||
|
pick_count=pick_count,
|
||||||
|
active="plan",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/api/plan/lock")
|
||||||
|
@require_session
|
||||||
|
def plan_lock():
|
||||||
|
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({"ok": False, "already_locked": True, "by": plan.get("locked_by_sub")})
|
||||||
|
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})
|
||||||
|
|
||||||
@app.get("/list")
|
@app.get("/list")
|
||||||
@require_session
|
@require_session
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@
|
||||||
<dt>logged in as</dt><dd>{{ mealie_user.username or mealie_user.email }}</dd>
|
<dt>logged in as</dt><dd>{{ mealie_user.username or mealie_user.email }}</dd>
|
||||||
<dt>full name</dt><dd>{{ mealie_user.fullName or '—' }}</dd>
|
<dt>full name</dt><dd>{{ mealie_user.fullName or '—' }}</dd>
|
||||||
<dt>role</dt><dd>{{ 'admin' if mealie_user.admin else 'member' }}</dd>
|
<dt>role</dt><dd>{{ 'admin' if mealie_user.admin else 'member' }}</dd>
|
||||||
|
{% if mealie_user.household %}
|
||||||
|
<dt>household</dt><dd>{{ mealie_user.household.name or mealie_user.household.slug }} · {{ household_size }} {% if household_size == 1 %}member{% else %}members{% endif %}</dd>
|
||||||
|
{% endif %}
|
||||||
</dl>
|
</dl>
|
||||||
<form method="post" action="/disconnect-mealie" class="btn-row">
|
<form method="post" action="/disconnect-mealie" class="btn-row">
|
||||||
<button class="btn btn-purple" type="submit">Disconnect</button>
|
<button class="btn btn-purple" type="submit">Disconnect</button>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,15 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div class="crumb">// picks</div>
|
<div class="crumb">// picks · household pool</div>
|
||||||
<h1>your <span class="accent">picks</span></h1>
|
<h1>the <span class="accent">pool</span></h1>
|
||||||
<div class="lede">recipes pinned for the next ai meal plan run. {% if picks %}{{ picks|length }} ready.{% else %}empty — pick some on the grimoire.{% endif %}</div>
|
<div class="lede">
|
||||||
|
{% if picks %}
|
||||||
|
{{ picks|length }} {% if picks|length == 1 %}recipe{% else %}recipes{% endif %} pinned by your household ({{ household_size }} {% if household_size == 1 %}member{% else %}members{% endif %}). next ai meal-plan run uses all of these.
|
||||||
|
{% else %}
|
||||||
|
empty pool. tap the mushroom 🍄 on any recipe in <a href="/recipes">the grimoire</a> to pin it for the next meal-plan run.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if picks %}
|
{% if picks %}
|
||||||
|
|
@ -16,9 +22,17 @@
|
||||||
</div>
|
</div>
|
||||||
<ul id="picks-list" style="list-style: none; padding: 0; margin: 0;">
|
<ul id="picks-list" style="list-style: none; padding: 0; margin: 0;">
|
||||||
{% for p in picks %}
|
{% for p in picks %}
|
||||||
<li data-slug="{{ p.recipe_slug }}" style="display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 10px 0; border-bottom: 1px solid var(--line-soft);">
|
<li data-slug="{{ p.slug }}" style="padding: 12px 0; border-bottom: 1px solid var(--line-soft);">
|
||||||
<a href="/recipes/{{ p.recipe_slug }}" style="flex: 1; color: var(--bone); font-family: var(--serif); font-size: 1.05em; border: none;">{{ p.recipe_name }}</a>
|
<div style="display: flex; justify-content: space-between; align-items: baseline; gap: 14px;">
|
||||||
<button class="btn" type="button" onclick="removePick('{{ p.recipe_slug }}', this)" style="font-size: 11px; padding: .4em 1em;">remove</button>
|
<a href="/recipes/{{ p.slug }}" style="flex: 1; color: var(--bone); font-family: var(--serif); font-size: 1.05em; border: none;">{{ p.name }}</a>
|
||||||
|
{% if p.mine %}
|
||||||
|
<button class="btn" type="button" onclick="removePick('{{ p.slug }}', this)" style="font-size: 11px; padding: .35em .9em;">unpin</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 4px; color: var(--muted); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; font-family: var(--mono);">
|
||||||
|
🍄 pinned by
|
||||||
|
{% for picker in p.pickers %}<span style="color: var(--green-bright);">{{ picker }}</span>{% if not loop.last %} · {% endif %}{% endfor %}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -28,13 +42,13 @@
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>next</h2>
|
<h2>next</h2>
|
||||||
</div>
|
</div>
|
||||||
<p>once the meal plan generator lands in <code>v0.3</code>, these picks become the seed — guaranteed slots for the week, with the ai filling in around them based on family prefs and what's in season.</p>
|
<p>once the meal plan generator lands in <code>v0.3</code>, this whole pool seeds the week — guaranteed slots for pinned dishes, ai fills around them.</p>
|
||||||
<p class="muted">for now, just pin and wait. you can always remove or add more.</p>
|
<p class="muted">picks are shared across your household. you can only unpin your own; ask whoever pinned the others to drop them.</p>
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<p>head to <a href="/recipes">the grimoire</a>, tap the mushroom 🍄 on any recipe to pin it.</p>
|
<p>head to <a href="/recipes">the grimoire</a>, tap the mushroom 🍄 on any recipe to pin it.</p>
|
||||||
<p class="muted">picks are per-user. bob's picks ≠ your picks.</p>
|
<p class="muted">picks are shared. bob's pins, your pins, carol's pins — all combine.</p>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
@ -48,7 +62,7 @@ async function removePick(slug, btn) {
|
||||||
if (li) li.remove();
|
if (li) li.remove();
|
||||||
if (!document.querySelectorAll('#picks-list li').length) location.reload();
|
if (!document.querySelectorAll('#picks-list li').length) location.reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
btn.disabled = false; btn.textContent = 'remove';
|
btn.disabled = false; btn.textContent = 'unpin';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
106
cauldron/templates/plan.html
Normal file
106
cauldron/templates/plan.html
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
{% extends "_base.html" %}
|
||||||
|
{% block title %}This Week · Cauldron{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="crumb">// plan · week of {{ week_start.strftime('%b %-d') }}</div>
|
||||||
|
<h1>this <span class="accent">week</span></h1>
|
||||||
|
<div class="lede">
|
||||||
|
{% if plan.locked_at %}
|
||||||
|
locked
|
||||||
|
{% if plan.locked_reason == 'user' and locked_by_display %}
|
||||||
|
by <span style="color: var(--green-bright);">{{ locked_by_display }}</span>
|
||||||
|
{% else %}
|
||||||
|
automatically
|
||||||
|
{% endif %}
|
||||||
|
· {{ plan.locked_at.strftime('%a %-I:%M %p') }}
|
||||||
|
{% else %}
|
||||||
|
pool has {{ pick_count }} pinned. ai generation lands in v0.3 — for now, lock when ready to mark this week done.
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel {% if plan.locked_at %}purple{% else %}green{% endif %}">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>state</h2>
|
||||||
|
{% if plan.locked_at %}
|
||||||
|
<span class="pill pill-mute">{{ 'auto-locked' if plan.locked_reason == 'auto' else 'locked' }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill pill-ok">open</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="ctx">week of {{ week_start.strftime('%b %-d, %Y') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if plan.locked_at %}
|
||||||
|
<p>this week's plan is locked. it'll archive Sunday night and a fresh week opens Monday.</p>
|
||||||
|
{% if plan.locked_reason == 'user' and plan.locked_by_sub == current_user_sub %}
|
||||||
|
<p class="muted">🏆 you locked this one in.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p>nothing's stopping you from locking right now. or wait — generator's coming and the race is part of the deal.</p>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
|
||||||
|
<a class="btn" href="/picks">view pool ({{ pick_count }})</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>scoreboard</h2>
|
||||||
|
<span class="ctx">user-locked weeks only</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if streak and streak.count >= 2 %}
|
||||||
|
<p style="font-size: 1.05em; margin: .2em 0 1em 0;">
|
||||||
|
🔥 <strong style="color: var(--green-bright);">{{ streak.display_name }}</strong>
|
||||||
|
on a <strong>{{ streak.count }}-week</strong> run.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if scoreboard and scoreboard|selectattr("wins")|list %}
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: .95em;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid var(--line);">
|
||||||
|
<th style="text-align: left; color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); font-weight: 600; padding: 6px 0;">member</th>
|
||||||
|
<th style="text-align: right; color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); font-weight: 600; padding: 6px 0;">wins</th>
|
||||||
|
<th style="text-align: right; color: var(--purple); font-size: 11px; letter-spacing: .15em; text-transform: uppercase; font-family: var(--mono); font-weight: 600; padding: 6px 0;">last</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in scoreboard %}
|
||||||
|
<tr style="border-bottom: 1px solid var(--line-soft);">
|
||||||
|
<td style="padding: 8px 0;">
|
||||||
|
{{ loop.index }}.
|
||||||
|
<strong style="color: {% if loop.first and s.wins %}var(--green-bright){% else %}var(--bone){% endif %};">
|
||||||
|
{{ s.display_name or s.email.split('@')[0] }}
|
||||||
|
</strong>
|
||||||
|
{% if s.sub == current_user_sub %}<span class="muted">· you</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; padding: 8px 0; font-family: var(--mono); color: var(--bone);">{{ s.wins or 0 }}</td>
|
||||||
|
<td style="text-align: right; padding: 8px 0; font-family: var(--mono); color: var(--muted); font-size: .85em;">
|
||||||
|
{{ s.last_win.strftime('%b %-d') if s.last_win else '—' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">nobody's locked a week yet. be the first.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function lockPlan(btn) {
|
||||||
|
btn.disabled = true; btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/plan/lock', { method: 'POST' });
|
||||||
|
if (!r.ok) throw new Error(r.status);
|
||||||
|
location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
btn.disabled = false; btn.innerHTML = '🔒 lock this week';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue