From 1540c2f43622d6679ecf40ef566c3ca723627c28 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 28 Apr 2026 21:11:11 -0700 Subject: [PATCH] v0.2: household-shared picks pool + /plan with lock button + scoreboard + streak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' 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 ('๐Ÿ”ฅ abby on a 3-week run') if streak >= 2 - me.html: shows household name + member count Locking semantics match Cobb'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). --- cauldron/db.py | 241 ++++++++++++++++++++++++++++++++++ cauldron/server.py | 121 ++++++++++++++++- cauldron/templates/me.html | 3 + cauldron/templates/picks.html | 34 +++-- cauldron/templates/plan.html | 106 +++++++++++++++ 5 files changed, 489 insertions(+), 16 deletions(-) create mode 100644 cauldron/templates/plan.html diff --git a/cauldron/db.py b/cauldron/db.py index 5134708..cd057aa 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -71,6 +71,45 @@ MIGRATIONS = [ INDEX idx_user_added (authentik_sub, added_at) ) 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), ) + # --- 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 --------------------------------------------------------- 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()} + 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 Cobb ยท Abby'). + 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 ----------------------------------------------------------- def log_chat( diff --git a/cauldron/server.py b/cauldron/server.py index 839d482..6fef3b5 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -21,6 +21,7 @@ Routes (current): POST /api/sterilize/preview/ (admin bearer) v0.1 sterilizer POST /api/sterilize/apply/ (admin bearer) v0.1 sterilizer """ +from datetime import date, datetime, timedelta from functools import wraps from flask import Flask, jsonify, redirect, render_template, request, session, url_for @@ -105,6 +106,40 @@ def create_app() -> Flask: return None 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 --------------------------------------------------- @app.get("/healthz") @@ -161,6 +196,7 @@ def create_app() -> Flask: u = session["user"] connected = db.get_user_mealie_token_blob(u["sub"]) is not None mealie_user = None + household_size = 0 if connected: client = current_user_mealie() if client: @@ -168,8 +204,14 @@ def create_app() -> Flask: mealie_user = client.who_am_i() except Exception: 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( - "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") @@ -209,6 +251,9 @@ def create_app() -> Flask: blob = crypto.encrypt(token) 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")) @app.post("/disconnect-mealie") @@ -234,7 +279,9 @@ def create_app() -> Flask: items = data.get("items", []) or [] total = data.get("total", len(items)) 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: it["picked"] = it.get("slug") in pick_slugs return render_template( @@ -256,7 +303,8 @@ def create_app() -> Flask: except Exception as e: return jsonify({"error": str(e)}), 502 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: it["picked"] = it.get("slug") in pick_slugs return jsonify({ @@ -303,13 +351,74 @@ def create_app() -> Flask: @require_session def picks_view(): u = session["user"] - picks = db.list_meal_picks(u["sub"]) - return render_template("picks.html", picks=picks, active="picks") + hid = current_household_id() + 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") @require_session 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") @require_session diff --git a/cauldron/templates/me.html b/cauldron/templates/me.html index 10518dc..352036a 100644 --- a/cauldron/templates/me.html +++ b/cauldron/templates/me.html @@ -30,6 +30,9 @@
logged in as
{{ mealie_user.username or mealie_user.email }}
full name
{{ mealie_user.fullName or 'โ€”' }}
role
{{ 'admin' if mealie_user.admin else 'member' }}
+ {% if mealie_user.household %} +
household
{{ mealie_user.household.name or mealie_user.household.slug }} ยท {{ household_size }} {% if household_size == 1 %}member{% else %}members{% endif %}
+ {% endif %}
diff --git a/cauldron/templates/picks.html b/cauldron/templates/picks.html index 4f1e702..414eaed 100644 --- a/cauldron/templates/picks.html +++ b/cauldron/templates/picks.html @@ -3,9 +3,15 @@ {% block content %}
-
// picks
-

your picks

-
recipes pinned for the next ai meal plan run. {% if picks %}{{ picks|length }} ready.{% else %}empty โ€” pick some on the grimoire.{% endif %}
+
// picks ยท household pool
+

the pool

+
+ {% 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 the grimoire to pin it for the next meal-plan run. + {% endif %} +
{% if picks %} @@ -16,9 +22,17 @@
    {% for p in picks %} -
  • - {{ p.recipe_name }} - +
  • +
    + {{ p.name }} + {% if p.mine %} + + {% endif %} +
    +
    + ๐Ÿ„ pinned by + {% for picker in p.pickers %}{{ picker }}{% if not loop.last %} ยท {% endif %}{% endfor %} +
  • {% endfor %}
@@ -28,13 +42,13 @@

next

-

once the meal plan generator lands in v0.3, 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.

-

for now, just pin and wait. you can always remove or add more.

+

once the meal plan generator lands in v0.3, this whole pool seeds the week โ€” guaranteed slots for pinned dishes, ai fills around them.

+

picks are shared across your household. you can only unpin your own; ask whoever pinned the others to drop them.

{% else %}

head to the grimoire, tap the mushroom ๐Ÿ„ on any recipe to pin it.

-

picks are per-user. abby's picks โ‰  your picks.

+

picks are shared. abby's pins, your pins, bay's pins โ€” all combine.

{% endif %} @@ -48,7 +62,7 @@ async function removePick(slug, btn) { if (li) li.remove(); if (!document.querySelectorAll('#picks-list li').length) location.reload(); } catch (e) { - btn.disabled = false; btn.textContent = 'remove'; + btn.disabled = false; btn.textContent = 'unpin'; } } diff --git a/cauldron/templates/plan.html b/cauldron/templates/plan.html new file mode 100644 index 0000000..815870a --- /dev/null +++ b/cauldron/templates/plan.html @@ -0,0 +1,106 @@ +{% extends "_base.html" %} +{% block title %}This Week ยท Cauldron{% endblock %} +{% block content %} + +
+
// plan ยท week of {{ week_start.strftime('%b %-d') }}
+

this week

+
+ {% if plan.locked_at %} + locked + {% if plan.locked_reason == 'user' and locked_by_display %} + by {{ locked_by_display }} + {% 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 %} +
+
+ +
+
+

state

+ {% if plan.locked_at %} + {{ 'auto-locked' if plan.locked_reason == 'auto' else 'locked' }} + {% else %} + open + {% endif %} + week of {{ week_start.strftime('%b %-d, %Y') }} +
+ + {% if plan.locked_at %} +

this week's plan is locked. it'll archive Sunday night and a fresh week opens Monday.

+ {% if plan.locked_reason == 'user' and plan.locked_by_sub == current_user_sub %} +

๐Ÿ† you locked this one in.

+ {% endif %} + {% else %} +

nothing's stopping you from locking right now. or wait โ€” generator's coming and the race is part of the deal.

+
+ + view pool ({{ pick_count }}) +
+ {% endif %} +
+ +
+
+

scoreboard

+ user-locked weeks only +
+ + {% if streak and streak.count >= 2 %} +

+ ๐Ÿ”ฅ {{ streak.display_name }} + on a {{ streak.count }}-week run. +

+ {% endif %} + + {% if scoreboard and scoreboard|selectattr("wins")|list %} + + + + + + + + + + {% for s in scoreboard %} + + + + + + {% endfor %} + +
memberwinslast
+ {{ loop.index }}. + + {{ s.display_name or s.email.split('@')[0] }} + + {% if s.sub == current_user_sub %}ยท you{% endif %} + {{ s.wins or 0 }} + {{ s.last_win.strftime('%b %-d') if s.last_win else 'โ€”' }} +
+ {% else %} +

nobody's locked a week yet. be the first.

+ {% endif %} +
+ + + +{% endblock %}