ui

tcmts.examples.polls.ui · #qpbsqbls · ts file

// tcm: namespace polls
// The browser side: it uses the DOM, so it runs in the browser. Calls to server functions look like local calls;
// `for await` over watchResults streams live tallies from the server.
import { chart, createPoll, leader, listPolls, samplePoll, sampleVotes, tally, vote, watchResults, type Poll } from "#codebase/polls";

/**
 * Make an element: `el("button", { onclick }, "Vote")`. Props are assigned to the element; children are appended.
 * A tiny stand-in for a UI framework, so the example stays dependency-free.
 */
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
  const e = document.createElement(tag);
  Object.assign(e, props);
  e.append(...children);
  return e;
}

/**
 * One poll on the page: a button per option (calling {@link vote} on the server), and a {@link chart} that
 * updates live from {@link watchResults}, with who's ahead ({@link leader}).
 */
async function showPoll(root: HTMLElement, poll: { id: string; question: string; options: string[]; open: boolean }) {
  const live = el("pre");
  const status = el("p");
  const buttons = poll.options.map((option, i) =>
    el("button", {
      disabled: !poll.open,
      onclick: async () => {
        try {
          live.textContent = chart(await vote(poll.id, i));
        } catch (e) {
          status.textContent = (e as Error).message;
        }
      },
    }, option),
  );
  root.append(el("section", {}, el("h2", {}, poll.question), ...buttons, live, status));
  for await (const t of watchResults(poll.id)) {
    live.textContent = chart(t);
    status.textContent = leader(t) ? `${leader(t)} is ahead` : "too close to call";
  }
  status.textContent = poll.open ? status.textContent : "closed";
}

/**
 * The app: a form to start a poll ({@link createPoll}) and every recent poll ({@link listPolls}), each shown by
 * {@link showPoll}. Run it with **▶ Start** in the editor's Apps view, or `tcm dev polls.main`.
 */
export async function main() {
  const root = el("main");
  document.body.append(root);
  const question = el("input", { placeholder: "Question" });
  const options = el("input", { placeholder: "Options, comma-separated" });
  const create = el("button", {
    onclick: async () => {
      await createPoll(question.value, options.value.split(",").map((s) => s.trim()).filter(Boolean), 5);
      location.reload();
    },
  }, "Start a 5-minute poll");
  root.append(el("h1", {}, "Polls"), question, options, create);
  for (const poll of await listPolls()) void showPoll(root, poll);
}

/**
 * # Polls
 *
 * A live polling app: start a poll, vote, and watch the results move. Nothing in it says what runs where; tcm
 * works that out from what each definition uses.
 *
 * | part | runs | what |
 * |---|---|---|
 * | {@link tally}, {@link leader}, {@link chart} | anywhere | the model: plain functions over a {@link Poll} |
 * | {@link vote}, {@link createPoll}, {@link listPolls} | server | called from the browser by hash |
 * | {@link watchResults} | server | streams a fresh tally every second (`for await` in the browser) |
 * | {@link polls.announce} | server | a background job, enqueued when a poll is created, run when it closes |
 * | {@link polls.nightly} | server | a recurring job that forgets old polls |
 * | {@link main} | browser | the page |
 *
 * Polls and votes live in typed tables ({@link polls.polls}, {@link polls.votes}): one vote per browser and poll,
 * so voting again changes your vote.
 *
 * Here's the model at work on the sample data:
 *
 * ```ts
 * chart(tally(samplePoll, sampleVotes))
 * ```
 *
 * Run it with **▶ Start** in the Apps view, or `tcm dev polls.main`.
 */
export type README = never;

used by