jevstrudel.git / tools / agreement / agreement.mjs
1#!/usr/bin/env node
2// How often listeners agree with Jev's art critic: the "do you agree with
3// Jev?" vote counts from the site (GET /jev/votes/summary,
4// worker/src/votes.ts), against each song's current `critic.art` in its
5// SPEC.md.
6//
7//   nix run .#agreement                       # the public site
8//   nix run .#agreement -- --url http://localhost:4324   # another deploy
9//
10// A vote agrees when its pick is the song the critic scores higher. A pair
11// the critic scores equally has no preference and is left out, as on the
12// page (agree.mjs), and so is a vote on a song no longer here or unscored.
13// 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';
18
19export { SUMMARY };
20
21// `critic.art` from a SPEC.md's frontmatter, or null when it has none.
22// Read the way tools/critic writes it: a top-level `critic:` block, `art:`
23// 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}
30
31// Every song under `root` (songs/<theme>/<song>/SPEC.md), shaped like the
32// 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}
46
47// The report is the page's own (website/src/jev/agree.mjs), which its
48// results view shows too: one definition, so the two never disagree.
49export const report = agreementReport;
50
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}