jevstrudel.git / tools / agreement / agreement.mjs
1#!/usr/bin/env node

How often listeners agree with Jev's art critic: the "do you agree with Jev?" vote counts from the site (GET /jev/votes/summary, worker/src/votes.ts), against each song's current critic.art in its SPEC.md.

nix run .#agreement # the public site nix run .#agreement -- --url http://localhost:4324 # another deploy

A vote agrees when its pick is the song the critic scores higher. A pair the critic scores equally has no preference and is left out, as on the page (agree.mjs), and so is a vote on a song no longer here or unscored. Per song, a vote counts for both songs in its pair.

14import { readdirSync, readFileSync, existsSync } from 'node:fs';
15import { fileURLToPath } from 'node:url';
16import { agreementReport, SUMMARY } from '../../website/src/jev/agree.mjs';
17import { siteUrl } from '../../website/src/jev/site.mjs';
19export { SUMMARY };

critic.art from a SPEC.md's frontmatter, or null when it has none. Read the way tools/critic writes it: a top-level critic: block, art: two spaces in.

24export function criticArt(spec) {
25  const fm = spec.split(/^---\s*$/m)[1] ?? '';
26  const block = fm.match(/^critic:[ \t]*\n((?:[ \t]+.*\n?)*)/m)?.[1] ?? '';
27  const art = Number(block.match(/^ {2}art:\s*([^\s#]+)/m)?.[1]);
28  return Number.isFinite(art) ? art : null;
29}

Every song under root (songs/<theme>/<song>/SPEC.md), shaped like the page's songs for agree.mjs: { id, art: { p } | null }.

33export function readSongs(root) {
34  const songs = [];
35  for (const theme of readdirSync(root, { withFileTypes: true })) {
36    if (!theme.isDirectory()) continue;
37    for (const song of readdirSync(new URL(`${theme.name}/`, root), { withFileTypes: true })) {
38      const spec = new URL(`${theme.name}/${song.name}/SPEC.md`, root);
39      if (!song.isDirectory() || !existsSync(spec)) continue;
40      const art = criticArt(readFileSync(spec, 'utf8'));
41      songs.push({ id: `${theme.name}/${song.name}`, art: art === null ? null : { p: art } });
42    }
43  }
44  return songs;
45}

The report is the page's own (website/src/jev/agree.mjs), which its results view shows too: one definition, so the two never disagree.

49export const report = agreementReport;
51const pct = (p) => (p === null ? '—' : `${Math.round(p * 100)}%`);
52
53export function format({ overall, songs }) {
54  const lines = [
55    `listeners agree with the critic ${pct(overall.share)} of the time (${overall.agreed} of ${overall.counted} votes)`,
56    `  of ${overall.votes} votes: ${overall.ties} on pairs the critic scores equally, ${overall.unknown} on songs no longer here or unscored, left out`,
57  ];
58  if (songs.length) {
59    const width = Math.max(...songs.map((s) => s.id.length));
60    lines.push('', `${'song'.padEnd(width)}  art   agree  votes`);
61    for (const s of songs) {
62      lines.push(
63        `${s.id.padEnd(width)}  ${pct(s.art).padStart(4)}  ${pct(s.share).padStart(5)}  ${String(s.counted).padStart(5)}`,
64      );
65    }
66  }
67  return lines.join('\n');
68}
69
70async function main(args) {
71  const at = args.indexOf('--url');
72  const base = at >= 0 ? args[at + 1] : siteUrl;
73  if (!base) throw new Error('usage: agreement [--url https://site]');
74  const url = new URL(SUMMARY, base);
75  const res = await fetch(url);
76  if (!res.ok) throw new Error(`${url} answered ${res.status}: ${(await res.text()).slice(0, 200)}`);
77  const { votes } = await res.json();
78  const songs = readSongs(new URL('../../songs/', import.meta.url));
79  console.log(`${url}\n`);
80  console.log(format(report(songs, votes)));
81}
82
83if (process.argv[1] === fileURLToPath(import.meta.url)) {
84  main(process.argv.slice(2)).catch((e) => {
85    console.error(`agreement: ${e.message}`);
86    process.exit(1);
87  });
88}