jevstrudel.git / website / src / jev / reactions.mjs
1// Every listener's 🔥/😴, across plays (worker/src/listening.ts). The
2// booth's buttons tell Jev how a section lands within one play (jevCore's
3// `audience`); each press is also sent here, anonymously, and counted per
4// (song, section, reaction). The tally comes back:
5//
6// - on the song's page (the jev panel's song view), per section;
7// - to Jev, as `listenersSoFar` in each request (jev.mjs hands jevCore
8//   `tallyFor(nowPlaying)`; jevCore cuts it to the sections that matter).
9//
10// The playing song's tally is fetched whenever the song changes, and a
11// press is added to it at once, so the page need not ask again.
12import { nowPlaying } from './client.mjs';
13
14export const REACTIONS = '/jev/reactions';
15
16// The name a reaction goes under: the form's section in `segment` (walk()),
17// 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}
23
24// 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};
33
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}
64
65// Counts one reaction on the site, fire and forget, and in this page's
66// 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}
85
86// 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}