newave

Smoke poll

Connecting…
Read-only view
// A poll: participants add options and vote for one. This is the starting
// draft of every new widget document; see docs/widgets.md for the API.
defineWidget({
  api: 1,
  name: "Smoke poll",

  init(state, args) {
    state.createText("question", (args && args.question) || "What do you think?");
    state.createList("options");
    state.createMap("votes"); // participant ID → option ID
  },

  actions: {
    addOption: {
      doc: "Add an option. args: {text: string}",
      run(state, { text }, ctx) {
        if (!text || !String(text).trim()) throw new Error("an option needs some text");
        const o = state.get("options").push();
        o.createText("text", String(text).trim());
        o.set("by", ctx.me.id);
      },
    },
    removeOption: {
      doc: "Remove an option. args: {option: id}",
      run(state, { option }) {
        const o = state.get("options").find(option);
        if (!o) throw new Error("no such option");
        state.get("options").remove(o);
      },
    },
    vote: {
      doc: "Vote for one option, replacing your vote. args: {option: id}",
      run(state, { option }, ctx) {
        if (!state.get("options").find(option)) throw new Error("no such option");
        state.get("votes").set(ctx.me.id, option);
      },
    },
    unvote: {
      doc: "Withdraw your vote.",
      run(state, _, ctx) {
        state.get("votes").delete(ctx.me.id);
      },
    },
    setQuestion: {
      doc: "Change the question. args: {text: string}",
      run(state, { text }) {
        state.get("question").update(String(text));
      },
    },
  },

  summary(state) {
    const lines = tally(state).map(({ option, n }) => `- ${option.get("text")} — ${n} vote${n === 1 ? "" : "s"} \`${option.id}\``);
    return `**${state.get("question")}**\n\n${lines.join("\n") || "_No options yet._"}`;
  },

  view({ root, state, ctx, act, subscribe }) {
    const style = document.createElement("style");
    style.textContent = `
      body { padding: .75rem 1rem; }
      h3 { margin: 0 0 .5rem; font-size: 1rem; }
      .option { display: flex; align-items: center; gap: .5rem; margin: .25rem 0; }
      .option button.vote { flex: 1; text-align: left; position: relative; overflow: hidden; }
      .option button.vote .bar { position: absolute; inset: 0 auto 0 0; background: #1c7ed622; }
      .option button.vote span { position: relative; }
      .option.chosen button.vote { border-color: #1c7ed6; font-weight: 600; }
      .remove { border: 0; background: none; color: #888; cursor: pointer; }
      form { display: flex; gap: .5rem; margin-top: .5rem; }
      form input { flex: 1; }
    `;
    const list = h("div");
    const question = h("h3");
    const input = h("input", { placeholder: "Add an option", maxLength: 200 });
    const form = h("form", {
      onsubmit: (e) => {
        e.preventDefault();
        if (!input.value.trim()) return;
        act("addOption", { text: input.value });
        input.value = "";
      },
    }, input, h("button", {}, "Add"));
    root.append(style, question, list, form);

    const render = () => {
      question.textContent = String(state.get("question"));
      const mine = state.get("votes").get(ctx.me.id);
      const counts = tally(state);
      const total = counts.reduce((sum, c) => sum + c.n, 0);
      list.replaceChildren(...counts.map(({ option, n }) => {
        const chosen = option.id === mine;
        const bar = h("div", { className: "bar" });
        bar.style.width = total ? `${(100 * n) / total}%` : "0";
        return h("div", { className: chosen ? "option chosen" : "option" },
          h("button", {
            className: "vote",
            title: chosen ? "Withdraw your vote" : "Vote for this",
            onclick: () => act(chosen ? "unvote" : "vote", { option: option.id }),
          }, bar, h("span", {}, `${option.get("text")} · ${n}`)),
          h("button", { className: "remove", title: "Remove", onclick: () => act("removeOption", { option: option.id }) }, "✕"));
      }));
    };
    subscribe(render);
    render();
  },
});

// tally counts the votes for each option. Votes for removed options are
// ignored, and come back if the option is restored.
function tally(state) {
  const votes = state.get("votes");
  return state.get("options").items().map((option) => ({
    option,
    n: votes.keys().filter((p) => votes.get(p) === option.id).length,
  }));
}

function h(tag, props, ...children) {
  const el = Object.assign(document.createElement(tag), props);
  el.append(...children);
  return el;
}