jevstrudel.git / website / src / jev / reactions.mjs

Every listener's 🔥/😴, across plays (worker/src/listening.ts). The booth's buttons tell Jev how a section lands within one play (jevCore's audience); each press is also sent here, anonymously, and counted per (song, section, reaction). The tally comes back:

  • on the song's page (the jev panel's song view), per section;
  • to Jev, as listenersSoFar in each request (jev.mjs hands jevCore tallyFor(nowPlaying); jevCore cuts it to the sections that matter).

The playing song's tally is fetched whenever the song changes, and a press is added to it at once, so the page need not ask again.

12import { nowPlaying } from './client.mjs';
14export const REACTIONS = '/jev/reactions';

The name a reaction goes under: the form's section in segment (walk()), or, for a song without a form, the section's number from 1.

18export function sectionOf(booth, segment) {
19  const name = booth?.form?.name;
20  const choice = name ? booth.decisions[segment]?.values[name]?.choice : null;
21  return choice ?? (name ? null : String(segment + 1));
22}

song id → { [section]: { fire, sleep } }

25const tallies = new Map();
26const loading = new Set();
27const listeners = new Set();
28let version = 0;
29const changed = () => {
30  version++;
31  for (const listener of listeners) listener();
32};
34export const tallyStore = {
35  subscribe(listener) {
36    listeners.add(listener);
37    return () => listeners.delete(listener);
38  },
39  // changes whenever any song's tally does
40  version: () => version,
41};
42
43export const tallyFor = (song) => (song ? (tallies.get(song) ?? null) : null);
44
45export async function loadTally(song, fetchImpl = globalThis.fetch) {
46  if (!song || loading.has(song)) return;
47  loading.add(song);
48  try {
49    const res = await fetchImpl(`${REACTIONS}?song=${encodeURIComponent(song)}`);
50    if (!res.ok) return;
51    const { sections } = await res.json();
52    if (!Array.isArray(sections)) return;
53    tallies.set(
54      song,
55      Object.fromEntries(sections.map(({ section, fire, sleep }) => [String(section), { fire: fire | 0, sleep: sleep | 0 }])),
56    );
57    changed();
58  } catch {
59    // no tally: the page shows none and Jev hears none
60  } finally {
61    loading.delete(song);
62  }
63}

Counts one reaction on the site, fire and forget, and in this page's copy of the tally straight away.

67export function sendReaction({ song, section, reaction }, fetchImpl = globalThis.fetch) {
68  if (!song || !section) return;
69  const tally = tallies.get(song) ?? {};
70  const row = (tally[section] ??= { fire: 0, sleep: 0 });
71  row[reaction] += 1;
72  tallies.set(song, tally);
73  changed();
74  try {
75    fetchImpl?.(REACTIONS, {
76      method: 'POST',
77      headers: { 'Content-Type': 'application/json' },
78      body: JSON.stringify({ song, section, reaction }),
79      keepalive: true,
80    })?.catch?.(() => {});
81  } catch {
82    // not counted
83  }
84}

In the page: the playing song's tally, fetched as each song is loaded.

87if (typeof window !== 'undefined') {
88  nowPlaying.subscribe(() => loadTally(nowPlaying.get()));
89  loadTally(nowPlaying.get());
90}