{"manifest":{"id":"campaign-night","version":"1.0.0","name":"Campaign Night","description":"For a tabletop group: find a date everyone can make, sort out hosting and snacks, run initiative at the table, and keep the recap.","entrypoint":"index.html","data_access":{"reads":["family.members"],"writes":[]},"permissions":{"default_audience":"everyone","requires_approval":false},"runtime":"static","icon":"🎲","nav":{"label":"Campaign"},"storage":"db","member_references":{"sessions":[{"column":"host_id","on_removed":"null"},{"column":"proposed_by","on_removed":"keep"}],"rsvps":{"column":"member_id","on_removed":"delete"}},"ai_access":{"allowed":true,"mode":"read","db_exports":["upcoming_sessions"]}},"files":{"index.html":"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n<title>Campaign Night</title>\n<style>\n  * { box-sizing: border-box; margin: 0; padding: 0; }\n  body {\n    background: var(--hub-bg, #f9fafb); color: var(--hub-text, #111);\n    font-family: var(--hub-font, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif);\n    font-size: var(--hub-font-size, 16px); line-height: 1.5;\n  }\n  .app { max-width: 640px; margin: 0 auto; padding: 16px 16px 40px; display: grid; gap: 14px; }\n  h1 { font-size: 1.35rem; }\n  h2 { font-size: 1.05rem; }\n  .muted { color: var(--hub-text-muted, #6b7280); }\n  .small { font-size: .875rem; }\n  .error { color: var(--hub-tint-danger-fg, #b91c1c); font-weight: 600; }\n  .tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--hub-border, #e5e7eb); }\n  .tab {\n    font: inherit; font-weight: 700; background: none; border: 0; border-bottom: 3px solid transparent;\n    padding: 8px 12px; cursor: pointer; color: var(--hub-text-muted, #6b7280);\n  }\n  .tab.on { color: var(--hub-text, #111); border-bottom-color: var(--hub-primary, #111827); }\n  .card {\n    background: var(--hub-surface, #fff); border: 1px solid var(--hub-border, #e5e7eb);\n    border-radius: var(--hub-radius, 12px); padding: 14px; display: grid; gap: 10px;\n  }\n  .card.best { border-color: var(--hub-primary, #111827); border-width: 2px; }\n  .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }\n  .spread { justify-content: space-between; }\n  .badge {\n    font-size: .75rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase;\n    background: var(--hub-primary, #111827); color: var(--hub-primary-fg, #fff); border-radius: 999px; padding: 2px 10px;\n  }\n  button.btn, .chip {\n    font: inherit; font-weight: 700; border-radius: 10px; padding: 8px 14px; cursor: pointer;\n    border: 1px solid var(--hub-border, #e5e7eb); background: transparent; color: var(--hub-text, #111);\n  }\n  button.primary { background: var(--hub-primary, #111827); color: var(--hub-primary-fg, #fff); border-color: var(--hub-primary, #111827); }\n  button.quiet { border: 0; padding: 4px 6px; font-weight: 600; color: var(--hub-text-muted, #6b7280); }\n  .chip { border-radius: 999px; padding: 6px 12px; font-weight: 600; }\n  .chip.in { background: var(--hub-primary, #111827); color: var(--hub-primary-fg, #fff); border-color: var(--hub-primary, #111827); }\n  .chip.maybe { border-style: dashed; border-width: 2px; }\n  .chip.out { color: var(--hub-text-muted, #6b7280); text-decoration: line-through; }\n  input, textarea {\n    font: inherit; font-size: 1rem; padding: 8px 10px; border-radius: 10px; min-width: 0;\n    border: 1px solid var(--hub-border, #e5e7eb); background: var(--hub-surface, #fff); color: var(--hub-text, #111);\n  }\n  input.grow, textarea.grow { flex: 1 1 180px; }\n  textarea { width: 100%; min-height: 5em; resize: vertical; }\n  input.num { width: 5.5em; }\n  .order { display: grid; gap: 6px; }\n  .turn {\n    display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-radius: 10px;\n    border: 1px solid var(--hub-border, #e5e7eb); background: var(--hub-bg, #f9fafb);\n  }\n  .turn.now { background: var(--hub-primary, #111827); color: var(--hub-primary-fg, #fff); border-color: var(--hub-primary, #111827); }\n  .turn .roll { font-weight: 800; font-variant-numeric: tabular-nums; }\n  .recap { white-space: pre-wrap; }\n</style>\n</head>\n<body>\n<div class=\"app\" id=\"root\">Loading…</div>\n<script type=\"module\">\nimport { esc, hubToday, hubConfirm, hubAppUrl, sendHubNotification } from \"/hub-sdk.js\";\n\nconst DB = window.__DB_URL ?? \"\";\nconst CONTEXT = window.__CONTEXT_URL ?? \"\";\nconst ME = window.__CURRENT_MEMBER ?? null;\nconst T = \"app_campaign_night__\";\nconst NEXT = { in: \"maybe\", maybe: \"out\", out: \"in\" };\nconst MARK = { in: \"✓ \", maybe: \"? \", out: \"\" };\n\nlet members = [];\nlet sessions = [];\nlet rsvps = [];\nlet combatants = [];\nlet tracker = null;\nlet tab = \"schedule\";\nlet error = \"\";\n\nasync function db(sql, params = []) {\n  if (!DB) return { rows: [] };\n  const res = await fetch(DB, {\n    method: \"POST\",\n    headers: { \"Content-Type\": \"application/json\" },\n    body: JSON.stringify({ sql, params }),\n  });\n  const json = await res.json();\n  if (json.error) throw new Error(json.error);\n  return json;\n}\n\n// Run a write, show what went wrong if it fails, then reload either way.\nasync function act(fn) {\n  error = \"\";\n  try { await fn(); } catch (e) { error = e.message; }\n  await load();\n}\n\nfunction memberName(id) {\n  const m = members.find((x) => x.id === id);\n  return m ? m.name : \"Someone\";\n}\n\n// Built from date PARTS: new Date(\"2026-10-17\") is UTC midnight and names the\n// wrong day west of it.\nfunction dateLabel(iso) {\n  const [y, m, d] = iso.split(\"-\").map(Number);\n  return new Date(y, m - 1, d).toLocaleDateString(undefined, { weekday: \"long\", month: \"long\", day: \"numeric\" });\n}\n\nasync function load() {\n  try {\n    if (CONTEXT) {\n      const res = await fetch(CONTEXT + \"?keys=family.members\");\n      members = (await res.json())[\"family.members\"] ?? [];\n    }\n    sessions = (await db(\"SELECT id, session_date, host_id, snacks, recap, proposed_by FROM \" + T + \"sessions ORDER BY session_date LIMIT 80\")).rows;\n    rsvps = (await db(\"SELECT id, session_id, member_id, status FROM \" + T + \"rsvps LIMIT 600\")).rows;\n    combatants = (await db(\"SELECT id, name, initiative FROM \" + T + \"combatants ORDER BY initiative DESC, created_at LIMIT 60\")).rows;\n    tracker = (await db(\"SELECT id, round_no, turn_no FROM \" + T + \"tracker LIMIT 1\")).rows[0] ?? null;\n  } catch (e) {\n    error = e.message;\n  }\n  render();\n}\n\n// ── Schedule ────────────────────────────────────────────────────────────\nfunction tally(sessionId) {\n  const mine = rsvps.filter((r) => r.session_id === sessionId);\n  return { in: mine.filter((r) => r.status === \"in\").length, maybe: mine.filter((r) => r.status === \"maybe\").length };\n}\n\nfunction bestSessionId(upcoming) {\n  let best = null;\n  for (const s of upcoming) {\n    const t = tally(s.id);\n    if (t.in === 0) continue;\n    if (!best || t.in > best.in || (t.in === best.in && t.maybe > best.maybe)) best = { id: s.id, in: t.in, maybe: t.maybe };\n  }\n  return best ? best.id : null;\n}\n\nfunction renderSchedule() {\n  const today = hubToday();\n  const upcoming = sessions.filter((s) => s.session_date >= today);\n  const best = bestSessionId(upcoming);\n  const cards = upcoming.map((s) => {\n    const t = tally(s.id);\n    const chips = members.map((m) => {\n      const r = rsvps.find((x) => x.session_id === s.id && x.member_id === m.id);\n      const status = r ? r.status : \"\";\n      return '<button class=\"chip ' + esc(status) + '\" data-rsvp=\"' + esc(s.id) + '\" data-member=\"' + esc(m.id) + '\">' +\n        (MARK[status] ?? \"\") + esc(m.name) + '</button>';\n    }).join(\"\");\n    let host = '<span class=\"muted\">Nobody is hosting yet.</span> <button class=\"btn\" data-host=\"' + esc(s.id) + '\">I will host</button>';\n    if (s.host_id) {\n      host = '<span>Hosting: <strong>' + esc(memberName(s.host_id)) + '</strong></span>' +\n        (ME && s.host_id === ME.id ? ' <button class=\"btn quiet\" data-unhost=\"' + esc(s.id) + '\">Release</button>' : \"\");\n    }\n    return '<section class=\"card' + (s.id === best ? \" best\" : \"\") + '\" data-session=\"' + esc(s.id) + '\">' +\n      '<div class=\"row spread\"><h2>' + esc(dateLabel(s.session_date)) + '</h2>' + (s.id === best ? '<span class=\"badge\">Best so far</span>' : \"\") + '</div>' +\n      '<div class=\"small muted\">' + t.in + ' in' + (t.maybe ? ', ' + t.maybe + ' maybe' : \"\") + ' · tap a name to change it</div>' +\n      '<div class=\"row\">' + chips + '</div>' +\n      '<div class=\"row\">' + host + '</div>' +\n      '<div class=\"row\"><input class=\"grow\" data-snacks-input=\"' + esc(s.id) + '\" placeholder=\"Snacks: who is bringing what?\" value=\"' + esc(s.snacks ?? \"\") + '\" />' +\n        '<button class=\"btn\" data-snacks=\"' + esc(s.id) + '\">Save</button></div>' +\n      '<div class=\"row spread\"><span class=\"small muted\">Proposed by ' + esc(memberName(s.proposed_by)) + '</span>' +\n        '<button class=\"btn quiet\" data-remove=\"' + esc(s.id) + '\">Remove date</button></div>' +\n    '</section>';\n  }).join(\"\");\n  return '<div class=\"card\"><h2>Propose a date</h2><div class=\"row\">' +\n      '<input type=\"date\" id=\"new-date\" min=\"' + esc(today) + '\" />' +\n      '<button class=\"btn primary\" id=\"propose\">Propose</button></div></div>' +\n    (cards || '<p class=\"muted\">No dates on the table yet. Propose one and the party will hear about it.</p>');\n}\n\nasync function propose() {\n  const input = document.getElementById(\"new-date\");\n  const date = input ? input.value : \"\";\n  if (!date) { error = \"Pick a date first.\"; return render(); }\n  if (!ME) { error = \"Open the app from the hub to propose a date.\"; return render(); }\n  if (sessions.some((s) => s.session_date === date)) { error = \"That date is already on the list.\"; return render(); }\n  let saved = false;\n  await act(async () => {\n    await db(\"INSERT INTO \" + T + \"sessions (id, session_date, proposed_by, created_at) VALUES (?, ?, ?, ?)\",\n      [crypto.randomUUID(), date, ME.id, new Date().toISOString()]);\n    saved = true;\n  });\n  if (!saved) return;\n  // After the write, never before it. A child may only notify the adults.\n  const sent = await sendHubNotification({\n    title: \"Campaign Night\",\n    body: ME.name + \" proposed \" + dateLabel(date) + \" for the next session\",\n    audience: ME.role === \"adult\" ? \"all\" : \"adults\",\n    url: hubAppUrl(),\n  });\n  if (!sent) { error = \"Saved, but the party could not be notified.\"; render(); }\n}\n\nasync function setRsvp(sessionId, memberId) {\n  const existing = rsvps.find((r) => r.session_id === sessionId && r.member_id === memberId);\n  const now = new Date().toISOString();\n  await act(async () => {\n    if (existing) {\n      await db(\"UPDATE \" + T + \"rsvps SET status = ?, updated_at = ? WHERE id = ?\", [NEXT[existing.status] ?? \"in\", now, existing.id]);\n    } else {\n      await db(\"INSERT INTO \" + T + \"rsvps (id, session_id, member_id, status, updated_at) VALUES (?, ?, ?, ?, ?)\",\n        [crypto.randomUUID(), sessionId, memberId, \"in\", now]);\n    }\n  });\n}\n\nasync function removeSession(id) {\n  const ok = await hubConfirm(\"Remove this date?\", { description: \"Everyone's answers for it are removed too.\", confirmLabel: \"Remove\" });\n  if (!ok) return;\n  await act(async () => {\n    await db(\"DELETE FROM \" + T + \"rsvps WHERE session_id = ?\", [id]);\n    await db(\"DELETE FROM \" + T + \"sessions WHERE id = ?\", [id]);\n  });\n}\n\n// ── Initiative ──────────────────────────────────────────────────────────\nfunction renderInitiative() {\n  const round = tracker ? tracker.round_no : 1;\n  const turn = tracker ? tracker.turn_no : 0;\n  const order = combatants.map((c, i) =>\n    '<div class=\"turn' + (i === turn ? \" now\" : \"\") + '\"><span>' + esc(c.name) + '</span>' +\n    '<span class=\"row\"><span class=\"roll\">' + esc(String(c.initiative)) + '</span>' +\n    '<button class=\"btn quiet\" data-drop=\"' + esc(c.id) + '\" aria-label=\"Remove ' + esc(c.name) + '\">✕</button></span></div>').join(\"\");\n  return '<div class=\"card\"><div class=\"row spread\"><h2>Round ' + esc(String(round)) + '</h2>' +\n      '<div class=\"row\"><button class=\"btn primary\" id=\"next-turn\"' + (combatants.length ? \"\" : \" disabled\") + '>Next turn</button>' +\n      '<button class=\"btn\" id=\"clear-fight\"' + (combatants.length ? \"\" : \" disabled\") + '>End fight</button></div></div>' +\n      '<div class=\"order\" id=\"order\">' + (order || '<p class=\"muted\">Nobody has rolled yet.</p>') + '</div></div>' +\n    '<div class=\"card\"><h2>Add to the order</h2>' +\n      '<div class=\"row\"><button class=\"btn\" id=\"roll-party\">Roll for the party</button>' +\n      '<span class=\"small muted\">A d20 for everyone in the group.</span></div>' +\n      '<div class=\"row\"><input class=\"grow\" id=\"c-name\" placeholder=\"Goblin boss\" />' +\n      '<input class=\"num\" id=\"c-roll\" type=\"number\" inputmode=\"numeric\" placeholder=\"Roll\" />' +\n      '<button class=\"btn\" id=\"add-combatant\">Add</button></div></div>';\n}\n\nasync function saveTracker(roundNo, turnNo) {\n  if (tracker) await db(\"UPDATE \" + T + \"tracker SET round_no = ?, turn_no = ? WHERE id = ?\", [roundNo, turnNo, tracker.id]);\n  else await db(\"INSERT INTO \" + T + \"tracker (id, round_no, turn_no) VALUES (?, ?, ?)\", [\"main\", roundNo, turnNo]);\n}\n\nasync function addCombatant(name, roll) {\n  await db(\"INSERT INTO \" + T + \"combatants (id, name, initiative, created_at) VALUES (?, ?, ?, ?)\",\n    [crypto.randomUUID(), name, roll, new Date().toISOString()]);\n}\n\nasync function nextTurn() {\n  if (!combatants.length) return;\n  let round = tracker ? tracker.round_no : 1;\n  let turn = (tracker ? tracker.turn_no : 0) + 1;\n  if (turn >= combatants.length) { turn = 0; round += 1; }\n  await act(() => saveTracker(round, turn));\n}\n\n// ── Recaps ──────────────────────────────────────────────────────────────\nfunction renderRecaps() {\n  const today = hubToday();\n  const played = sessions.filter((s) => s.session_date <= today).reverse();\n  const list = played.map((s) =>\n    '<section class=\"card\"><h2>' + esc(dateLabel(s.session_date)) + '</h2>' +\n    (s.recap ? '<p class=\"recap\">' + esc(s.recap) + '</p>' : '<p class=\"muted\">No recap written yet.</p>') +\n    '<textarea data-recap-input=\"' + esc(s.id) + '\" placeholder=\"What happened?\">' + esc(s.recap ?? \"\") + '</textarea>' +\n    '<div class=\"row\"><button class=\"btn\" data-recap=\"' + esc(s.id) + '\">Save recap</button></div></section>').join(\"\");\n  return '<div class=\"card\"><h2>Log a session you played</h2>' +\n      '<div class=\"row\"><input type=\"date\" id=\"played-date\" max=\"' + esc(today) + '\" /></div>' +\n      '<textarea id=\"played-recap\" placeholder=\"Previously, on our campaign…\"></textarea>' +\n      '<div class=\"row\"><button class=\"btn primary\" id=\"log-played\">Save to the log</button></div></div>' +\n    (list || '<p class=\"muted\">Nothing in the log yet.</p>');\n}\n\nasync function logPlayed() {\n  const date = document.getElementById(\"played-date\").value;\n  const recap = document.getElementById(\"played-recap\").value.trim();\n  if (!date) { error = \"Pick the date you played.\"; return render(); }\n  if (!ME) { error = \"Open the app from the hub to add to the log.\"; return render(); }\n  const existing = sessions.find((s) => s.session_date === date);\n  await act(async () => {\n    // An empty recap is stored as NULL, never as an empty string.\n    if (existing) await db(\"UPDATE \" + T + \"sessions SET recap = ? WHERE id = ?\", [recap || null, existing.id]);\n    else await db(\"INSERT INTO \" + T + \"sessions (id, session_date, recap, proposed_by, created_at) VALUES (?, ?, ?, ?, ?)\",\n      [crypto.randomUUID(), date, recap || null, ME.id, new Date().toISOString()]);\n  });\n}\n\n// ── Shell ───────────────────────────────────────────────────────────────\nfunction render() {\n  const tabs = [[\"schedule\", \"Next session\"], [\"initiative\", \"Initiative\"], [\"recaps\", \"Recaps\"]].map(([id, label]) =>\n    '<button class=\"tab' + (tab === id ? \" on\" : \"\") + '\" data-tab=\"' + id + '\">' + label + '</button>').join(\"\");\n  const body = tab === \"schedule\" ? renderSchedule() : tab === \"initiative\" ? renderInitiative() : renderRecaps();\n  document.getElementById(\"root\").innerHTML =\n    '<h1>Campaign Night</h1><div class=\"tabs\">' + tabs + '</div>' +\n    (error ? '<p class=\"error\" id=\"error\">' + esc(error) + '</p>' : \"\") + body;\n}\n\n// One delegated listener. Inline on*= attributes cannot see module-scoped functions.\ndocument.addEventListener(\"click\", async (event) => {\n  const el = event.target.closest(\"button\");\n  if (!el) return;\n  const d = el.dataset;\n  if (d.tab) { tab = d.tab; error = \"\"; return render(); }\n  if (el.id === \"propose\") return propose();\n  if (d.rsvp) return setRsvp(d.rsvp, d.member);\n  if (d.host) {\n    if (!ME) { error = \"Open the app from the hub to host.\"; return render(); }\n    return act(() => db(\"UPDATE \" + T + \"sessions SET host_id = ? WHERE id = ?\", [ME.id, d.host]));\n  }\n  if (d.unhost) return act(() => db(\"UPDATE \" + T + \"sessions SET host_id = ? WHERE id = ?\", [null, d.unhost]));\n  if (d.snacks) {\n    const value = document.querySelector('[data-snacks-input=\"' + d.snacks + '\"]').value.trim();\n    return act(() => db(\"UPDATE \" + T + \"sessions SET snacks = ? WHERE id = ?\", [value || null, d.snacks]));\n  }\n  if (d.remove) return removeSession(d.remove);\n  if (el.id === \"next-turn\") return nextTurn();\n  if (el.id === \"roll-party\") {\n    return act(async () => {\n      for (const m of members) await addCombatant(m.name, 1 + Math.floor(Math.random() * 20));\n      await saveTracker(tracker ? tracker.round_no : 1, 0);\n    });\n  }\n  if (el.id === \"add-combatant\") {\n    const name = document.getElementById(\"c-name\").value.trim();\n    const roll = Number(document.getElementById(\"c-roll\").value);\n    if (!name || !Number.isFinite(roll)) { error = \"Give it a name and a roll.\"; return render(); }\n    return act(async () => { await addCombatant(name, Math.round(roll)); await saveTracker(tracker ? tracker.round_no : 1, 0); });\n  }\n  if (d.drop) return act(() => db(\"DELETE FROM \" + T + \"combatants WHERE id = ?\", [d.drop]));\n  if (el.id === \"clear-fight\") {\n    const ok = await hubConfirm(\"End the fight?\", { description: \"The initiative order is cleared.\", confirmLabel: \"End fight\" });\n    if (!ok) return;\n    return act(async () => {\n      await db(\"DELETE FROM \" + T + \"combatants WHERE initiative > ?\", [-100000]);\n      await saveTracker(1, 0);\n    });\n  }\n  if (d.recap) {\n    const value = document.querySelector('[data-recap-input=\"' + d.recap + '\"]').value.trim();\n    return act(() => db(\"UPDATE \" + T + \"sessions SET recap = ? WHERE id = ?\", [value || null, d.recap]));\n  }\n  if (el.id === \"log-played\") return logPlayed();\n});\n\nload();\n</script>\n</body>\n</html>\n","queries/upcoming_sessions.sql":"SELECT session_date, host_id\nFROM app_campaign_night__sessions\nWHERE session_date >= :today\nORDER BY session_date\nLIMIT 20"},"migrations":[{"version":1,"sql":"CREATE TABLE IF NOT EXISTS app_campaign_night__sessions (\n  id           TEXT PRIMARY KEY,\n  session_date TEXT NOT NULL,\n  host_id      TEXT,\n  snacks       TEXT,\n  recap        TEXT,\n  proposed_by  TEXT NOT NULL,\n  created_at   TEXT NOT NULL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS app_campaign_night__sessions_date_idx\n  ON app_campaign_night__sessions (session_date);\nCREATE TABLE IF NOT EXISTS app_campaign_night__rsvps (\n  id         TEXT PRIMARY KEY,\n  session_id TEXT NOT NULL,\n  member_id  TEXT NOT NULL,\n  status     TEXT NOT NULL,\n  updated_at TEXT NOT NULL\n);\nCREATE UNIQUE INDEX IF NOT EXISTS app_campaign_night__rsvps_one_idx\n  ON app_campaign_night__rsvps (session_id, member_id);\nCREATE TABLE IF NOT EXISTS app_campaign_night__combatants (\n  id         TEXT PRIMARY KEY,\n  name       TEXT NOT NULL,\n  initiative INTEGER NOT NULL,\n  created_at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS app_campaign_night__tracker (\n  id       TEXT PRIMARY KEY,\n  round_no INTEGER NOT NULL,\n  turn_no  INTEGER NOT NULL\n);"}]}
