jevstrudel.git / website / src / jev / VoteResults.jsx
VoteResults.jsx115 lines · 4.8 KB · raw
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}