1// Everyone's "do you agree with Jev?" votes (agree.mjs's voteResults, from 2// GET /jev/votes/summary): the songs as listeners rank them, pair by pair 3// (Bradley–Terry), beside Jev's own rank by art; how often listeners agree 4// with Jev; and the pairs where they outvote it. Counts are shown 5// everywhere, so a ranking from a handful of votes reads as one. 6import { useEffect, useState } from 'react'; 7import { SUMMARY, voteResults } from './agree.mjs'; 8import { themeColor as ACCENT } from './site.mjs'; 9 10const pct = (p) => `${Math.round(p * 100)}%`; 11// Fewer votes than this on a song, and its place is a guess: shown faded. 12export const FEW_VOTES = 5; 13 14async function readSummary(fetchImpl = globalThis.fetch) { 15 const res = await fetchImpl(SUMMARY, { cache: 'no-store' }); 16 if (!res.ok) throw new Error(res.status === 429 ? 'too many requests; try again in a minute' : `the site answered ${res.status}`); 17 const { votes } = await res.json(); 18 if (!Array.isArray(votes)) throw new Error('the counts are malformed'); 19 return votes; 20} 21 22export function VoteResults({ songs }) { 23 const [state, setState] = useState({ loading: true }); 24 useEffect(() => { 25 let live = true; 26 readSummary() 27 .then((counts) => live && setState({ results: voteResults(songs, counts) })) 28 .catch((e) => live && setState({ error: e.message })); 29 return () => { 30 live = false; 31 }; 32 // the site's songs are this build's, fixed for the page's life: read once 33 // eslint-disable-next-line react-hooks/exhaustive-deps 34 }, []); 35 36 if (state.loading) return <p className="text-xs opacity-70">Counting everyone's votes…</p>; 37 if (state.error) return <p className="text-xs text-red-400">The votes could not be read: {state.error}</p>; 38 const { overall, songs: ranked, disagreements } = state.results; 39 const title = new Map(ranked.map((s) => [s.id, s.title])); 40 if (!overall.votes) return <p className="text-xs opacity-70">No votes yet: yours would be the first.</p>; 41 42 return ( 43 <div className="text-xs space-y-3 mt-2" data-testid="vote-results"> 44 <p> 45 {overall.share === null ? ( 46 'No vote yet on a pair Jev has a preference between.' 47 ) : ( 48 <> 49 Listeners agree with Jev <b style={{ color: ACCENT }}>{pct(overall.share)}</b> of the time ({overall.agreed} of{' '} 50 {overall.counted} votes). 51 </> 52 )}{' '} 53 <span className="opacity-70"> 54 {overall.votes} votes in all 55 {overall.ties ? `; ${overall.ties} on pairs Jev scores equally` : ''} 56 {overall.unknown ? `; ${overall.unknown} on songs no longer here or unscored` : ''}. 57 </span> 58 </p> 59 60 <table className="w-full border-collapse"> 61 <thead> 62 <tr className="text-left opacity-70"> 63 <th className="font-normal pr-2">listeners</th> 64 <th className="font-normal pr-2">song</th> 65 <th className="font-normal pr-2" title="Jev's rank by its art score">Jev</th> 66 <th className="font-normal pr-2" title="the chance to beat an average song, from every pair it was in"> 67 beats avg 68 </th> 69 <th className="font-normal" title="the pairs it won, of the votes on pairs it was in"> 70 won 71 </th> 72 </tr> 73 </thead> 74 <tbody> 75 {ranked.map((s) => ( 76 <tr 77 key={s.id} 78 className="border-t border-muted" 79 style={{ opacity: s.games < FEW_VOTES ? 0.55 : 1 }} 80 title={s.games < FEW_VOTES ? `only ${s.games} votes: its place is a guess` : undefined} 81 > 82 <td className="pr-2">#{s.listenerRank}</td> 83 <td className="pr-2 truncate max-w-[12rem]">{s.title}</td> 84 <td className="pr-2 whitespace-nowrap"> 85 #{s.jevRank} · {pct(s.art)} 86 </td> 87 <td className="pr-2">{s.games ? pct(s.beatsAverage) : '—'}</td> 88 <td className="whitespace-nowrap"> 89 {s.wins} of {s.games} 90 </td> 91 </tr> 92 ))} 93 </tbody> 94 </table> 95 <p className="opacity-60"> 96 Ranked pair by pair (a Bradley–Terry model), with every song starting from one win and one loss against an average 97 song, so a few votes move it only a little. Faded: fewer than {FEW_VOTES} votes. 98 </p> 99 100 {disagreements.length > 0 && ( 101 <div> 102 <b>Where listeners outvote Jev</b> 103 <ul className="list-disc pl-5 mt-1 space-y-0.5"> 104 {disagreements.slice(0, 10).map((d) => ( 105 <li key={`${d.jev}|${d.listeners}`}> 106 <b>{title.get(d.listeners)}</b> over <b>{title.get(d.jev)}</b>, {d.against}–{d.withJev}{' '} 107 <span className="opacity-70">(Jev prefers {title.get(d.jev)})</span> 108 </li> 109 ))} 110 </ul> 111 </div> 112 )} 113 </div> 114 ); 115}