{"manifest":{"id":"thermostat-wars","version":"1.0.0","name":"Thermostat Wars","description":"Somebody touched the thermostat. Report it, vote on a suspect, enter a plea, and let the household hand down a verdict.","entrypoint":"index.html","data_access":{"reads":["family.members"],"writes":[]},"permissions":{"default_audience":"everyone","requires_approval":false},"runtime":"static","icon":"🌡️","nav":{"label":"Thermostat"},"storage":"db","row_policies":{"cases":{"kind":"member_writable","frozen_when":{"status_column":"status","locked_values":["closed"]}},"votes":{"kind":"sealed_until","fk_column":"case_id","parent_table":"cases","writer_column":"member_id","parent_status_column":"status","visible_parent_status_values":["open","closed"],"frozen_when":{"status_column":"status","locked_values":["closed"]},"column_write_acls":{"member_id":{"writable_by":[],"actions":["update"]},"case_id":{"writable_by":[],"actions":["update"]}}},"pleas":{"kind":"sealed_until","fk_column":"case_id","parent_table":"cases","writer_column":"member_id","parent_status_column":"status","visible_parent_status_values":["open","closed"],"frozen_when":{"status_column":"status","locked_values":["closed"]},"column_write_acls":{"member_id":{"writable_by":[],"actions":["update"]},"case_id":{"writable_by":[],"actions":["update"]}}}},"member_references":{"cases":{"column":"reported_by","on_removed":"keep"},"votes":[{"column":"member_id","on_removed":"delete"},{"column":"suspect_id","on_removed":"delete"}],"pleas":{"column":"member_id","on_removed":"delete"}}},"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>Thermostat Wars</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  .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }\n  .spread { justify-content: space-between; }\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  .temp { font-size: 2.4rem; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }\n  .badge {\n    font-size: .75rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase;\n    border-radius: 999px; padding: 2px 10px; border: 1px solid var(--hub-border, #e5e7eb);\n  }\n  .badge.open { background: var(--hub-primary, #111827); color: var(--hub-primary-fg, #fff); border-color: var(--hub-primary, #111827); }\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.mine { background: var(--hub-primary, #111827); color: var(--hub-primary-fg, #fff); border-color: var(--hub-primary, #111827); }\n  .chip:disabled { cursor: default; opacity: 1; }\n  .chip .n { font-variant-numeric: tabular-nums; opacity: .8; margin-left: 4px; }\n  input {\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 { flex: 1 1 180px; }\n  input.num { width: 5.5em; }\n  .verdict { font-size: 1.15rem; font-weight: 800; }\n  table { width: 100%; border-collapse: collapse; }\n  th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--hub-border, #e5e7eb); }\n  th { font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--hub-text-muted, #6b7280); }\n  td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }\n</style>\n</head>\n<body>\n<div class=\"app\" id=\"root\">Loading…</div>\n<script type=\"module\">\nimport { esc, 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_thermostat_wars__\";\nconst PLEAS = [[\"guilty\", \"Guilty\"], [\"not-me\", \"Not me\"], [\"dog\", \"It was the dog\"]];\nconst PLEA_TEXT = { guilty: \"pleads guilty\", \"not-me\": \"says it was not them\", dog: \"blames the dog\" };\n\nlet members = [];\nlet cases = [];\nlet votes = [];\nlet pleas = [];\nlet tab = \"docket\";\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\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\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    cases = (await db(\"SELECT id, temp, note, status, reported_by, opened_at FROM \" + T + \"cases ORDER BY opened_at DESC LIMIT 60\")).rows;\n    votes = (await db(\"SELECT case_id, member_id, suspect_id FROM \" + T + \"votes LIMIT 1000\")).rows;\n    pleas = (await db(\"SELECT case_id, member_id, plea FROM \" + T + \"pleas LIMIT 1000\")).rows;\n  } catch (e) {\n    error = e.message;\n  }\n  render();\n}\n\n// Votes against each suspect in one case, most first.\nfunction tally(caseId) {\n  const counts = new Map();\n  for (const v of votes) if (v.case_id === caseId) counts.set(v.suspect_id, (counts.get(v.suspect_id) ?? 0) + 1);\n  return [...counts.entries()].sort((a, b) => b[1] - a[1]);\n}\n\n// The one suspect with the most votes, or null for a tie or an empty ballot.\nfunction convicted(caseId) {\n  const t = tally(caseId);\n  if (!t.length || (t.length > 1 && t[0][1] === t[1][1])) return null;\n  return t[0][0];\n}\n\nfunction renderCase(c) {\n  const open = c.status === \"open\";\n  const t = tally(c.id);\n  const total = t.reduce((sum, x) => sum + x[1], 0);\n  const mine = ME ? votes.find((v) => v.case_id === c.id && v.member_id === ME.id) : null;\n  const chips = members.map((m) => {\n    const n = (t.find((x) => x[0] === m.id) ?? [null, 0])[1];\n    return '<button class=\"chip' + (mine && mine.suspect_id === m.id ? \" mine\" : \"\") + '\" data-vote=\"' + esc(c.id) + '\" data-suspect=\"' + esc(m.id) + '\"' +\n      (open ? \"\" : \" disabled\") + '>' + esc(m.name) + (n ? '<span class=\"n\">' + n + '</span>' : \"\") + '</button>';\n  }).join(\"\");\n  const entered = pleas.filter((p) => p.case_id === c.id).map((p) =>\n    '<li>' + esc(memberName(p.member_id)) + ' ' + esc(PLEA_TEXT[p.plea] ?? \"has something to say\") + '.</li>').join(\"\");\n  let verdict = \"\";\n  if (!open) {\n    const who = convicted(c.id);\n    verdict = who\n      ? '<p class=\"verdict\">Guilty: ' + esc(memberName(who)) + '. ' + t[0][1] + ' of ' + total + ' votes.</p>'\n      : '<p class=\"verdict\">Hung jury. The thermostat walks free.</p>';\n  }\n  const pleaButtons = open && ME\n    ? '<div class=\"row\"><span class=\"small muted\">Your plea:</span>' + PLEAS.map(([id, label]) =>\n        '<button class=\"btn\" data-plea=\"' + esc(c.id) + '\" data-value=\"' + id + '\">' + label + '</button>').join(\"\") + '</div>'\n    : \"\";\n  return '<section class=\"card\" data-case=\"' + esc(c.id) + '\">' +\n    '<div class=\"row spread\"><span class=\"temp\">' + esc(String(c.temp)) + '°</span>' +\n      '<span class=\"badge ' + (open ? \"open\" : \"\") + '\">' + (open ? \"Case open\" : \"Case closed\") + '</span></div>' +\n    '<div class=\"small muted\">Reported by ' + esc(memberName(c.reported_by)) + (c.note ? ' · “' + esc(c.note) + '”' : \"\") + '</div>' +\n    verdict +\n    '<div><div class=\"small muted\">' + (open ? \"Who did it? One vote each. Tap to change yours.\" : \"The votes\") + '</div>' +\n      '<div class=\"row\" style=\"margin-top:6px\">' + chips + '</div></div>' +\n    (entered ? '<ul class=\"small\" style=\"padding-left:1.2em\">' + entered + '</ul>' : \"\") +\n    pleaButtons +\n    (open ? '<div class=\"row\"><button class=\"btn primary\" data-close=\"' + esc(c.id) + '\">Close the case</button></div>' : \"\") +\n  '</section>';\n}\n\nfunction renderDocket() {\n  return '<div class=\"card\"><h2>Report the thermostat</h2><div class=\"row\">' +\n      '<input class=\"num\" id=\"temp\" type=\"number\" inputmode=\"numeric\" placeholder=\"74\" aria-label=\"Temperature you found\" />' +\n      '<input class=\"grow\" id=\"note\" placeholder=\"Found it like this at 7am\" aria-label=\"Note\" />' +\n      '<button class=\"btn primary\" id=\"open-case\">Open a case</button></div></div>' +\n    (cases.map(renderCase).join(\"\") || '<p class=\"muted\">No cases. Either peace has broken out or nobody has checked.</p>');\n}\n\nfunction renderRapSheet() {\n  const rows = members.map((m) => ({\n    name: m.name,\n    accused: votes.filter((v) => v.suspect_id === m.id).length,\n    convictions: cases.filter((c) => c.status === \"closed\" && convicted(c.id) === m.id).length,\n  })).sort((a, b) => b.convictions - a.convictions || b.accused - a.accused);\n  const top = rows[0] && rows[0].convictions > 0 ? rows[0].name : null;\n  return '<div class=\"card\"><h2>Rap sheet</h2>' +\n    (top ? '<p>Most wanted: <strong>' + esc(top) + '</strong></p>' : '<p class=\"muted\">No convictions yet.</p>') +\n    '<table><thead><tr><th>Member</th><th class=\"n\">Convictions</th><th class=\"n\">Votes against</th></tr></thead><tbody>' +\n    rows.map((r) => '<tr><td>' + esc(r.name) + '</td><td class=\"n\">' + r.convictions + '</td><td class=\"n\">' + r.accused + '</td></tr>').join(\"\") +\n    '</tbody></table></div>';\n}\n\nfunction render() {\n  const tabs = [[\"docket\", \"The docket\"], [\"rap\", \"Rap sheet\"]].map(([id, label]) =>\n    '<button class=\"tab' + (tab === id ? \" on\" : \"\") + '\" data-tab=\"' + id + '\">' + label + '</button>').join(\"\");\n  document.getElementById(\"root\").innerHTML =\n    '<div class=\"row spread\"><h1>Thermostat Wars</h1><button class=\"btn quiet\" id=\"refresh\">Refresh</button></div>' +\n    '<div class=\"tabs\">' + tabs + '</div>' +\n    (error ? '<p class=\"error\" id=\"error\">' + esc(error) + '</p>' : \"\") +\n    (tab === \"docket\" ? renderDocket() : renderRapSheet());\n}\n\nasync function openCase() {\n  const temp = Number(document.getElementById(\"temp\").value);\n  const note = document.getElementById(\"note\").value.trim();\n  if (!Number.isFinite(temp) || temp < 5 || temp > 110) { error = \"Enter the temperature you found.\"; return render(); }\n  if (!ME) { error = \"Open the app from the hub to report a case.\"; return render(); }\n  let saved = false;\n  await act(async () => {\n    // An empty note is stored as NULL, never as an empty string.\n    await db(\"INSERT INTO \" + T + \"cases (id, temp, note, status, reported_by, opened_at) VALUES (?, ?, ?, ?, ?, ?)\",\n      [crypto.randomUUID(), Math.round(temp), note || null, \"open\", ME.id, new Date().toISOString()]);\n    saved = true;\n  });\n  if (!saved) return;\n  const sent = await sendHubNotification({\n    title: \"Thermostat Wars\",\n    body: ME.name + \" opened a case: it is \" + Math.round(temp) + \"° in here. Who did this?\",\n    audience: ME.role === \"adult\" ? \"all\" : \"adults\",\n    url: hubAppUrl(),\n  });\n  if (!sent) { error = \"Case opened, but the household could not be notified.\"; render(); }\n}\n\n// Change my row; if I have none yet, add it. The hub writes MY id into\n// member_id whatever is sent here, and refuses both once the case is closed.\nasync function upsertMine(table, column, caseId, value) {\n  if (!ME) { error = \"Open the app from the hub to take part.\"; return render(); }\n  await act(async () => {\n    const changed = await db(\"UPDATE \" + T + table + \" SET \" + column + \" = ? WHERE case_id = ? AND member_id = ?\", [value, caseId, ME.id]);\n    if (!changed.changed) {\n      await db(\"INSERT INTO \" + T + table + \" (id, case_id, member_id, \" + column + \", created_at) VALUES (?, ?, ?, ?, ?)\",\n        [crypto.randomUUID(), caseId, ME.id, value, new Date().toISOString()]);\n    }\n  });\n}\n\nasync function closeCase(id) {\n  const ok = await hubConfirm(\"Close the case?\", { description: \"The votes are final and a closed case cannot be reopened.\", confirmLabel: \"Close the case\" });\n  if (!ok) return;\n  await act(async () => {\n    const res = await db(\"UPDATE \" + T + \"cases SET status = ? WHERE id = ? AND status = ?\", [\"closed\", id, \"open\"]);\n    if (!res.changed) throw new Error(\"That case was already closed.\");\n  });\n}\n\n// One delegated listener. Inline on*= attributes cannot see module-scoped functions.\ndocument.addEventListener(\"click\", (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 === \"refresh\") return load();\n  if (el.id === \"open-case\") return openCase();\n  if (d.vote) return upsertMine(\"votes\", \"suspect_id\", d.vote, d.suspect);\n  if (d.plea) return upsertMine(\"pleas\", \"plea\", d.plea, d.value);\n  if (d.close) return closeCase(d.close);\n});\n// Other people vote from their own phones; pick that up when this one is looked at again.\ndocument.addEventListener(\"visibilitychange\", () => { if (!document.hidden) load(); });\n\nload();\n</script>\n</body>\n</html>\n"},"migrations":[{"version":1,"sql":"CREATE TABLE IF NOT EXISTS app_thermostat_wars__cases (\n  id          TEXT PRIMARY KEY,\n  temp        INTEGER NOT NULL,\n  note        TEXT,\n  status      TEXT NOT NULL,\n  reported_by TEXT NOT NULL,\n  opened_at   TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS app_thermostat_wars__votes (\n  id         TEXT PRIMARY KEY,\n  case_id    TEXT NOT NULL REFERENCES app_thermostat_wars__cases(id) ON DELETE CASCADE,\n  member_id  TEXT NOT NULL,\n  suspect_id TEXT NOT NULL,\n  created_at TEXT NOT NULL,\n  UNIQUE (case_id, member_id)\n);\nCREATE TABLE IF NOT EXISTS app_thermostat_wars__pleas (\n  id         TEXT PRIMARY KEY,\n  case_id    TEXT NOT NULL REFERENCES app_thermostat_wars__cases(id) ON DELETE CASCADE,\n  member_id  TEXT NOT NULL,\n  plea       TEXT NOT NULL,\n  created_at TEXT NOT NULL,\n  UNIQUE (case_id, member_id)\n);"}]}
