model

tcmts.examples.polls.model · #bl4cri65 · ts file

// tcm: namespace polls
// The model: plain functions over plain data. Nothing here touches Node or the DOM, so tcm places all of it
// "anywhere": the server, the browser and tests share the same hashes.
import assert from "node:assert/strict";

/**
 * A question with a few options, open for votes until `closesAt`.
 *
 * Stored in the `polls` table; {@link isOpen} says whether it still takes votes.
 */
export type Poll = { question: string; options: string[]; closesAt: Date };

/**
 * One voter's choice in one poll: `option` is an index into the poll's {@link Poll | options}.
 *
 * Stored in the `votes` table, one row per poll and voter.
 */
export type Vote = { poll: string; voter: string; option: number };

/**
 * A poll's results: one row per option, most votes first, each with its share of all the votes (0 to 1).
 * Made by {@link tally}; drawn by {@link chart}; {@link leader} reads the winner off it.
 */
export type Tally = { option: string; votes: number; share: number }[];

/**
 * Count the votes for each option, most votes first. Options with the same number of votes keep the poll's
 * order. Votes for options the poll doesn't have are ignored.
 *
 * @param poll the poll whose options are counted
 * @param votes votes for that poll (from any number of voters)
 * @returns a {@link Tally}, one row per option
 *
 * ```ts
 * tally(samplePoll, sampleVotes)
 * tally(samplePoll, [])
 * ```
 */
export function tally(poll: Poll, votes: Vote[]): Tally {
  const counts = poll.options.map(() => 0);
  for (const v of votes) if (v.option >= 0 && v.option < counts.length) counts[v.option]!++;
  const total = counts.reduce((a, b) => a + b, 0);
  return poll.options
    .map((option, i) => ({ option, votes: counts[i]!, share: total ? counts[i]! / total : 0 }))
    .sort((a, b) => b.votes - a.votes);
}

/**
 * The option in the lead, or `undefined` while it's a tie for first (or nobody has voted).
 *
 * @param t a {@link Tally}, as made by {@link tally}
 *
 * ```ts
 * leader(tally(samplePoll, sampleVotes))
 * leader(tally(samplePoll, sampleVotes.slice(0, 2)))
 * ```
 */
export function leader(t: Tally): string | undefined {
  const [first, second] = t;
  if (!first || first.votes === 0) return undefined;
  return !second || first.votes > second.votes ? first.option : undefined;
}

/**
 * A text bar chart of a {@link Tally}: one line per option, with a bar, the option's share and its votes. The
 * browser shows this in a `<pre>`; it's text, so it's the same everywhere.
 *
 * @param t the results to draw
 * @param width how many characters a full bar takes
 *
 * ```ts
 * chart(tally(samplePoll, sampleVotes))
 * chart(tally(samplePoll, sampleVotes), 4)
 * ```
 */
export function chart(t: Tally, width = 10): string {
  const label = Math.max(0, ...t.map((r) => r.option.length));
  return t
    .map((r) => {
      const filled = Math.round(r.share * width);
      const pct = `${Math.round(r.share * 100)}%`.padStart(4);
      return `${r.option.padEnd(label)}  ${"█".repeat(filled)}${"░".repeat(width - filled)} ${pct} (${r.votes})`;
    })
    .join("\n");
}

/**
 * Whether a poll still takes votes at `now`.
 *
 * ```ts
 * isOpen(samplePoll, new Date("2029-12-31"))
 * isOpen(samplePoll, new Date("2030-01-02"))
 * ```
 */
export const isOpen = (poll: Poll, now: Date): boolean => now.getTime() < poll.closesAt.getTime();

/**
 * What a closed poll says about itself, as the `announce` job logs it when the poll closes.
 *
 * ```ts
 * verdict(samplePoll, tally(samplePoll, sampleVotes))
 * verdict(samplePoll, tally(samplePoll, sampleVotes.slice(0, 2)))
 * ```
 */
export function verdict(poll: Poll, t: Tally): string {
  const total = t.reduce((n, r) => n + r.votes, 0);
  const winner = leader(t);
  if (!total) return `"${poll.question}": nobody voted`;
  return winner ? `"${poll.question}": ${winner} wins with ${t[0]!.votes} of ${total} votes` : `"${poll.question}": a tie`;
}

// ---- sample data and tests ----------------------------------------------------------------------------------

/** A poll to try things on (it closes in 2030). Used by the examples in these docs and by the tests. */
export const samplePoll: Poll = { question: "Tabs or spaces?", options: ["tabs", "spaces", "both"], closesAt: new Date("2030-01-01") };

/** Four votes for {@link samplePoll}: three for tabs, one for spaces. */
export const sampleVotes: Vote[] = [
  { poll: "p", voter: "ada", option: 0 },
  { poll: "p", voter: "grace", option: 1 },
  { poll: "p", voter: "linus", option: 0 },
  { poll: "p", voter: "ken", option: 0 },
];

/** {@link tally} counts every vote and sorts by votes. */
export const testTallyCountsAndSorts = () => {
  const t = tally(samplePoll, sampleVotes);
  assert.deepEqual(t.map((r) => [r.option, r.votes]), [["tabs", 3], ["spaces", 1], ["both", 0]]);
  assert.equal(t[0]!.share, 0.75);
};

/** {@link leader} needs a clear lead: a tie, or no votes, has no leader. */
export const testLeaderNeedsAClearLead = () => {
  assert.equal(leader(tally(samplePoll, sampleVotes)), "tabs");
  assert.equal(leader(tally(samplePoll, sampleVotes.slice(0, 2))), undefined, "1-1 is a tie");
  assert.equal(leader(tally(samplePoll, [])), undefined);
};

/** {@link verdict} names the winner, or says nobody voted. */
export const testVerdict = () => {
  assert.equal(verdict(samplePoll, tally(samplePoll, sampleVotes)), `"Tabs or spaces?": tabs wins with 3 of 4 votes`);
  assert.equal(verdict(samplePoll, tally(samplePoll, [])), `"Tabs or spaces?": nobody voted`);
};

used by