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}