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 ('🔥 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).
This commit is contained in:
parent
adec91486c
commit
1540c2f436
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)
|
||||
) 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(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ Routes (current):
|
|||
POST /api/sterilize/preview/<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 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
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@
|
|||
<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>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>
|
||||
<form method="post" action="/disconnect-mealie" class="btn-row">
|
||||
<button class="btn btn-purple" type="submit">Disconnect</button>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@
|
|||
{% block content %}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="crumb">// picks</div>
|
||||
<h1>your <span class="accent">picks</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="crumb">// picks · household pool</div>
|
||||
<h1>the <span class="accent">pool</span></h1>
|
||||
<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>
|
||||
|
||||
{% if picks %}
|
||||
|
|
@ -16,9 +22,17 @@
|
|||
</div>
|
||||
<ul id="picks-list" style="list-style: none; padding: 0; margin: 0;">
|
||||
{% 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);">
|
||||
<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>
|
||||
<button class="btn" type="button" onclick="removePick('{{ p.recipe_slug }}', this)" style="font-size: 11px; padding: .4em 1em;">remove</button>
|
||||
<li data-slug="{{ p.slug }}" style="padding: 12px 0; border-bottom: 1px solid var(--line-soft);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline; gap: 14px;">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
@ -28,13 +42,13 @@
|
|||
<div class="panel-head">
|
||||
<h2>next</h2>
|
||||
</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 class="muted">for now, just pin and wait. you can always remove or add more.</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">picks are shared across your household. you can only unpin your own; ask whoever pinned the others to drop them.</p>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="panel">
|
||||
<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. abby's picks ≠ your picks.</p>
|
||||
<p class="muted">picks are shared. abby's pins, your pins, bay's pins — all combine.</p>
|
||||
</section>
|
||||
{% 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';
|
||||
}
|
||||
}
|
||||
</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