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.
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.
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}