jevstrudel.git / website / src / jev / AgreeWithJev.jsx
AgreeWithJev.jsx101 lines · 3.7 KB · raw
1// The welcome tab's "do you agree with Jev?" (agree.mjs): two songs, the
2// listener picks the one that is more art, then sees what the art critic
3// scored them and how often the two of them agree. The tally stays in this
4// browser; each vote is also counted, anonymously, on the site (sendVote).
5import { useState } from 'react';
6import { readSongs } from './client.mjs';
7import { agreement, jevPrefers, nextPair, readVotes, sendVote, writeVotes } from './agree.mjs';
8import { themeColor as ACCENT } from './site.mjs';
9import { VoteResults } from './VoteResults.jsx';
10
11const pct = (p) => `${Math.round(p * 100)}%`;
12const storage = () => (typeof window === 'undefined' ? null : window.localStorage);
13
14export function AgreeWithJev() {
15  const songs = readSongs();
16  const [votes, setVotes] = useState(() => readVotes(storage()));
17  const [pair, setPair] = useState(() => nextPair(songs, votes));
18  const [voted, setVoted] = useState(null); // the id picked for this pair
19  const [results, setResults] = useState(false); // everyone's votes, opened
20  if (!pair) return null;
21
22  const tally = agreement(songs, votes);
23  const [a, b] = pair;
24  const jev = jevPrefers(a, b);
25
26  const vote = (id) => {
27    const cast = { a: a.id, b: b.id, pick: id };
28    const next = [...votes, cast];
29    writeVotes(storage(), next);
30    sendVote(cast);
31    setVotes(next);
32    setVoted(id);
33  };
34  const another = () => {
35    setPair(nextPair(songs, votes));
36    setVoted(null);
37  };
38  const reset = () => {
39    writeVotes(storage(), []);
40    setVotes([]);
41  };
42
43  const choice = (song) => (
44    <button
45      key={song.id}
46      className="flex-1 min-w-0 border rounded px-2 py-2 text-left hover:opacity-80 disabled:hover:opacity-100"
47      style={{ borderColor: voted === song.id ? ACCENT : undefined }}
48      disabled={!!voted}
49      onClick={() => vote(song.id)}
50    >
51      <div className="font-bold truncate">{song.title}</div>
52      {voted && (
53        <div className="text-xs opacity-80">
54          Jev: {pct(song.art.p)} art{jev === song.id && ' ← Jev’s pick'}
55        </div>
56      )}
57    </button>
58  );
59
60  return (
61    <details className="not-prose my-2">
62      <summary className="cursor-pointer">
63        🎨 do you agree with Jev?
64        {tally.counted > 0 && (
65          <span className="opacity-70">
66            {' '}
67            you agree {pct(tally.share)} of the time ({tally.agreed} of {tally.counted})
68          </span>
69        )}
70      </summary>
71      <p className="text-xs opacity-80 my-2">
72        Which of these two is more art? Votes are counted anonymously, to see how often listeners agree with Jev.
73      </p>
74      <div className="flex gap-2">{[a, b].map(choice)}</div>
75      {/* read only once opened: a closed details still mounts its children */}
76      <details className="mt-2" data-testid="vote-results-toggle" onToggle={(e) => setResults(e.currentTarget.open)}>
77        <summary className="cursor-pointer text-xs opacity-80 hover:opacity-100">
78          everyone's votes: how listeners rank the songs, and where they outvote Jev
79        </summary>
80        {results && <VoteResults songs={songs} />}
81      </details>
82      {voted && (
83        <div className="flex flex-wrap items-center gap-3 text-xs mt-2">
84          <span>
85            {!jev
86              ? 'Jev scores them the same, so this one does not count.'
87              : jev === voted
88                ? 'You and Jev agree.'
89                : 'Jev disagrees.'}
90          </span>
91          <button className="border rounded px-2 py-1" style={{ borderColor: ACCENT }} onClick={another}>
92            another pair
93          </button>
94          <button className="opacity-60 hover:opacity-100" onClick={reset}>
95            forget my votes
96          </button>
97        </div>
98      )}
99    </details>
100  );
101}