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:
Kayos 2026-04-28 21:11:11 -07:00
parent adec91486c
commit 1540c2f436
5 changed files with 489 additions and 16 deletions

View file

@ -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(

View file

@ -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

View file

@ -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>

View file

@ -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>

View 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 %}