@thebattlearena/corner 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/ring/agents/cornerman.md +20 -3
  4. package/ring/agents/wrestler.md +11 -0
  5. package/ring/hooks/hooks.json +20 -0
  6. package/ring/scripts/bout-stats-render.mjs +366 -0
  7. package/ring/scripts/bout-stats.mjs +360 -0
  8. package/ring/scripts/config.mjs +1 -1
  9. package/ring/scripts/contract-endpoints.mjs +163 -0
  10. package/ring/scripts/contract-preview.mjs +34 -14
  11. package/ring/scripts/fight.mjs +27 -0
  12. package/ring/scripts/findings.mjs +3 -1
  13. package/ring/scripts/format-gate.mjs +723 -0
  14. package/ring/scripts/format-worker.mjs +120 -0
  15. package/ring/scripts/front-routes.mjs +345 -0
  16. package/ring/scripts/judges-card.mjs +324 -3
  17. package/ring/scripts/lookout.mjs +22 -4
  18. package/ring/scripts/mask-guard.mjs +34 -1
  19. package/ring/scripts/page-embed.mjs +112 -0
  20. package/ring/scripts/play-by-play.mjs +155 -7
  21. package/ring/scripts/preparation-collect.mjs +177 -0
  22. package/ring/scripts/preparation.mjs +547 -0
  23. package/ring/scripts/round-bell.mjs +9 -2
  24. package/ring/scripts/tech-plan-log.mjs +81 -0
  25. package/ring/scripts/tech-plan.mjs +191 -0
  26. package/ring/scripts/verdicts-log.mjs +75 -0
  27. package/ring/scripts/verdicts.mjs +205 -0
  28. package/ring/skills/play-by-play/SKILL.md +235 -33
  29. package/ring/skills/play-by-play/templates/blocks.css +196 -0
  30. package/ring/skills/play-by-play/templates/chronicle.html +358 -55
  31. package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
  32. package/ring/skills/play-by-play/templates/interview-form.js +516 -0
  33. package/ring/skills/play-by-play/templates/now-panel.js +719 -0
  34. package/ring/skills/referee/SKILL.md +136 -42
  35. package/ring/skills/referee/references/api-diagram.md +56 -0
  36. package/ring/skills/referee/references/precedents.md +11 -0
  37. package/ring/skills/referee/references/rulebook.md +65 -4
  38. package/ring/skills/referee/templates/api-diagram.html +115 -0
  39. package/ring/skills/referee/templates/escalation.md +7 -1
  40. package/ring/skills/referee/templates/round-bell-body.md +4 -0
  41. package/ring/skills/referee/templates/wrestler-brief.md +3 -0
  42. package/ring/skills/sparring/SKILL.md +10 -2
  43. package/ring/skills/sparring/templates/contrato.md +29 -0
  44. package/ring/skills/sparring/templates/rudo-brief.md +7 -0
  45. package/ring/skills/weigh-in/SKILL.md +6 -0
@@ -0,0 +1,719 @@
1
+ // «Qué necesita de ti ahora», the dark panel on top of the chronicle (`section[data-ring-now]`):
2
+ // what the judge has to do, counted live from the page's bus (`window.ringPage`, which
3
+ // interview-form.js and endpoints-view.js fill from `respuestas` and `veredictos`) and from each
4
+ // bout's line the skill wrote (`.now-bout[data-pr][data-label][data-frozen]`). Per bout it shows
5
+ // the next question and the next endpoints or routes with their controls, so the judge answers and
6
+ // judges without going down; «Ver la cola» shows the rest. «Aceptar las N recomendaciones del
7
+ // réferi» saves the recommended option of every unanswered question only after a dialog that lists
8
+ // each one (Julio: nothing is saved without him knowing). With nothing to do, the panel says who
9
+ // has the ball and the latest news (`.now-ball`, `.now-news`). It also runs «Abrir todo» of «La
10
+ // crónica» and opens the folded stage an in-page link points into.
11
+ (() => {
12
+ const ring = (window.ringPage ??= {
13
+ questions: new Map(),
14
+ boards: new Map(),
15
+ scales: {},
16
+ changed() {
17
+ document.dispatchEvent(new CustomEvent('ring:change'));
18
+ },
19
+ });
20
+
21
+ const el = (tag, attrs = {}, ...children) => {
22
+ const node = document.createElement(tag);
23
+ Object.entries(attrs).forEach(([key, value]) => {
24
+ if (value === undefined || value === null || value === false) return;
25
+ if (key === 'text') return void (node.textContent = value);
26
+ if (key.startsWith('on'))
27
+ return void node.addEventListener(key.slice(2), value);
28
+ node.setAttribute(key, value === true ? '' : value);
29
+ });
30
+ children
31
+ .flat(Infinity)
32
+ .filter(
33
+ (child) => child !== null && child !== undefined && child !== false,
34
+ )
35
+ .forEach((child) => node.append(child));
36
+ return node;
37
+ };
38
+ /** replaceChildren that flattens arrays and skips null, undefined and false, like `el`. */
39
+ const put = (node, ...children) =>
40
+ node.replaceChildren(
41
+ ...children
42
+ .flat(Infinity)
43
+ .filter((child) => child !== null && child !== undefined && child !== false),
44
+ );
45
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
46
+ const list = (items) =>
47
+ items.length < 2
48
+ ? items.join('')
49
+ : `${items.slice(0, -1).join(', ')} y ${items.at(-1)}`;
50
+
51
+ // «La crónica»: «Abrir todo» and the stage a link points into.
52
+ document.querySelectorAll('[data-ring-open-all]').forEach((button) => {
53
+ button.addEventListener('click', () => {
54
+ const stages = [...document.querySelectorAll('details.stage')];
55
+ const open = !stages.every((stage) => stage.open);
56
+ stages.forEach((stage) => (stage.open = open));
57
+ button.textContent = open ? 'Cerrar todo' : 'Abrir todo';
58
+ });
59
+ });
60
+ const reveal = (target) => {
61
+ for (let node = target; node; node = node.parentElement)
62
+ if (node.tagName === 'DETAILS') node.open = true;
63
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
64
+ };
65
+ document.addEventListener('click', (event) => {
66
+ const link = event.target.closest?.('a[href^="#"]');
67
+ const target =
68
+ link && link.getAttribute('href').length > 1
69
+ ? document.getElementById(
70
+ decodeURIComponent(link.getAttribute('href').slice(1)),
71
+ )
72
+ : null;
73
+ if (!target) return;
74
+ event.preventDefault();
75
+ history.replaceState(null, '', link.getAttribute('href'));
76
+ reveal(target);
77
+ });
78
+ if (location.hash.length > 1) {
79
+ const target = document.getElementById(
80
+ decodeURIComponent(location.hash.slice(1)),
81
+ );
82
+ if (target) setTimeout(() => reveal(target), 300);
83
+ }
84
+
85
+ const panel = document.querySelector('section[data-ring-now]');
86
+ if (!panel) return;
87
+ // The ball and the news of the whole fight sit outside the bouts; a bout's own sit inside its
88
+ // div.now-bout and are kept (moved, never lost) into its card on every repaint.
89
+ const fightNotes = [...panel.querySelectorAll('.now-ball, .now-news')].filter(
90
+ (node) => !node.closest('.now-bout'),
91
+ );
92
+ const bouts = [...panel.querySelectorAll('.now-bout[data-pr]')].map(
93
+ (node) => ({
94
+ node,
95
+ pr: Number(node.dataset.pr),
96
+ label: node.dataset.label || `#${node.dataset.pr}`,
97
+ frozen: (node.dataset.frozen || '').trim(),
98
+ notes: [...node.querySelectorAll('.now-ball, .now-news')],
99
+ }),
100
+ );
101
+ /** Where a bout starts in «La crónica»: its h3.bout in an open stage, else its first one. */
102
+ const boutTarget = (pr) => {
103
+ const heads = [
104
+ ...document.querySelectorAll(
105
+ `h3.bout[data-pr="${pr}"], h3.bout[id="bout-${pr}"], h3.bout[id^="bout-${pr}-"]`,
106
+ ),
107
+ ];
108
+ return heads.find((head) => head.closest('details.stage[open]')) ?? heads[0];
109
+ };
110
+ const head = el('div', { class: 'now-head' });
111
+ const counters = el('div', { class: 'now-counters' });
112
+ const actions = el('div', { class: 'now-actions' });
113
+ const boutsBox =
114
+ panel.querySelector('.now-bouts') ?? el('div', { class: 'now-bouts' });
115
+ const status = el('div', { class: 'now-status' }, fightNotes);
116
+ panel.prepend(head, counters);
117
+ panel.append(boutsBox, actions, status);
118
+
119
+ /** An image drop for the panel's forms; keeps the uploaded asset ids in `attached`. */
120
+ function attacher(attached, say) {
121
+ const file = el('input', {
122
+ type: 'file',
123
+ accept: 'image/*',
124
+ multiple: true,
125
+ hidden: true,
126
+ });
127
+ const thumbs = el('div', { class: 'thumbs' });
128
+ const add = async (files) => {
129
+ for (const picked of files) {
130
+ say(`Subiendo ${picked.name}…`);
131
+ try {
132
+ if (!ring.upload) throw new Error('Esta vista no puede adjuntar');
133
+ const up = await ring.upload(picked);
134
+ attached.push(up.id);
135
+ thumbs.append(el('img', { src: up.url, alt: picked.name }));
136
+ say('');
137
+ } catch (error) {
138
+ say(error.message);
139
+ }
140
+ }
141
+ };
142
+ file.addEventListener('change', () => add([...file.files]));
143
+ const drop = el('div', {
144
+ class: 'drop',
145
+ tabindex: '0',
146
+ text: 'Arrastra imágenes aquí o haz clic para adjuntar',
147
+ onclick: () => file.click(),
148
+ onkeydown: (event) => ['Enter', ' '].includes(event.key) && file.click(),
149
+ ondragover: (event) => {
150
+ event.preventDefault();
151
+ drop.classList.add('over');
152
+ },
153
+ ondragleave: () => drop.classList.remove('over'),
154
+ ondrop: (event) => {
155
+ event.preventDefault();
156
+ drop.classList.remove('over');
157
+ add([...event.dataTransfer.files]);
158
+ },
159
+ });
160
+ return [drop, file, thumbs];
161
+ }
162
+
163
+ // Each form is built once and kept, so typing, a picked option or an image survive a repaint.
164
+ const questionForms = new Map();
165
+ function questionForm(question) {
166
+ if (questionForms.has(question.id)) return questionForms.get(question.id);
167
+ const name = `now-${question.id}`;
168
+ const say = (text) => (state.textContent = text);
169
+ const attached = [];
170
+ const note = el('textarea', {
171
+ placeholder: 'Comentario para el réferi (opcional)',
172
+ });
173
+ const extra = el(
174
+ 'div',
175
+ { class: 'now-extra', hidden: true },
176
+ note,
177
+ attacher(attached, say),
178
+ );
179
+ const state = el('span', { class: 'status', role: 'status' });
180
+ const save = el('button', {
181
+ type: 'button',
182
+ class: 'save',
183
+ text: 'Guardar respuesta',
184
+ disabled: true,
185
+ onclick: async () => {
186
+ const picked = form.querySelector(`input[name="${name}"]:checked`);
187
+ if (!picked) return say('Elige una opción');
188
+ const option = question.options.find(
189
+ (entry) => entry.key === picked.value,
190
+ );
191
+ save.disabled = true;
192
+ say('Guardando…');
193
+ try {
194
+ await question.save({
195
+ choice: option.key,
196
+ text: option.text(),
197
+ note: note.value,
198
+ attachments: attached,
199
+ });
200
+ say('Guardada');
201
+ } catch (error) {
202
+ say(error.message);
203
+ save.disabled = false;
204
+ }
205
+ },
206
+ });
207
+ const form = el(
208
+ 'div',
209
+ { class: 'now-q', id: `now-${question.id}` },
210
+ el(
211
+ 'div',
212
+ { class: 'now-q-head' },
213
+ el('b', { text: question.title }),
214
+ question.time ? el('time', { text: question.time }) : null,
215
+ question.hasContext
216
+ ? el('button', {
217
+ type: 'button',
218
+ class: 'more',
219
+ text: 'Ver más información',
220
+ onclick: () =>
221
+ ring.openContext?.(question.title, question.context),
222
+ })
223
+ : null,
224
+ ),
225
+ el(
226
+ 'div',
227
+ {
228
+ class: 'now-opts',
229
+ role: 'radiogroup',
230
+ onchange: () => (save.disabled = false),
231
+ },
232
+ question.options.map((option) =>
233
+ el(
234
+ 'label',
235
+ { class: `now-opt${option.rec ? ' rec' : ''}` },
236
+ el('input', { type: 'radio', name, value: option.key }),
237
+ el('i', { text: option.key }),
238
+ el('span', { text: option.text() }),
239
+ option.rec ? el('em', { text: 'Recomienda el réferi' }) : null,
240
+ ),
241
+ ),
242
+ ),
243
+ el(
244
+ 'div',
245
+ { class: 'now-row q-actions' },
246
+ save,
247
+ el('button', {
248
+ type: 'button',
249
+ class: 'ghost',
250
+ text: 'Comentario o imagen',
251
+ onclick: () => (extra.hidden = !extra.hidden),
252
+ }),
253
+ el('a', {
254
+ href: `#${question.id}`,
255
+ class: 'jump',
256
+ text: 'En la crónica',
257
+ }),
258
+ state,
259
+ ),
260
+ extra,
261
+ );
262
+ questionForms.set(question.id, form);
263
+ return form;
264
+ }
265
+
266
+ const verdictRows = new Map();
267
+ function verdictRow(item) {
268
+ const known = verdictRows.get(item.key);
269
+ if (known) {
270
+ known.item = item;
271
+ known.node.classList.toggle('stale', item.verdict === 'stale');
272
+ return known.node;
273
+ }
274
+ const entry = { item };
275
+ const attached = [];
276
+ const say = (text) => (state.textContent = text);
277
+ const state = el('span', { class: 'status', role: 'status' });
278
+ const comment = el('textarea', {
279
+ placeholder: '¿Qué hay que mejorar? (obligatorio)',
280
+ });
281
+ const send = async (verdict) => {
282
+ say('Guardando…');
283
+ try {
284
+ await entry.item.save(verdict, comment.value, attached);
285
+ say('Guardado');
286
+ } catch (error) {
287
+ say(error.message);
288
+ }
289
+ };
290
+ const improve = el(
291
+ 'div',
292
+ { class: 'now-extra', hidden: true },
293
+ comment,
294
+ attacher(attached, say),
295
+ el(
296
+ 'div',
297
+ { class: 'now-row' },
298
+ el('button', {
299
+ type: 'button',
300
+ class: 'save',
301
+ text: 'Guardar «se mejora»',
302
+ onclick: () => send('improve'),
303
+ }),
304
+ ),
305
+ );
306
+ entry.node = el(
307
+ 'div',
308
+ { class: `now-ep${item.verdict === 'stale' ? ' stale' : ''}` },
309
+ el(
310
+ 'div',
311
+ { class: 'now-ep-line' },
312
+ el('b', { class: `m ${item.chipClass}`, text: item.chip }),
313
+ el('a', { href: `#${item.key}`, class: 'path', text: item.path }),
314
+ el('span', { class: 'was', text: 'desactualizado' }),
315
+ el(
316
+ 'span',
317
+ { class: 'now-ep-buttons' },
318
+ el('button', {
319
+ type: 'button',
320
+ class: 'approve',
321
+ text: '✓ Se aprueba',
322
+ onclick: () => send('approved'),
323
+ }),
324
+ el('button', {
325
+ type: 'button',
326
+ class: 'improve-btn',
327
+ text: '✎ Se mejora',
328
+ onclick: () => (improve.hidden = !improve.hidden),
329
+ }),
330
+ ),
331
+ ),
332
+ state,
333
+ improve,
334
+ );
335
+ verdictRows.set(item.key, entry);
336
+ return entry.node;
337
+ }
338
+
339
+ const expanded = new Set();
340
+ const pendingOf = (pr) => {
341
+ const questions = [...ring.questions.values()]
342
+ .filter((question) => question.pr === pr)
343
+ .sort(
344
+ (a, b) =>
345
+ a.esc.localeCompare(b.esc, 'es', { numeric: true }) || a.n - b.n,
346
+ );
347
+ const boards = [...ring.boards.values()].filter((board) => board.pr === pr);
348
+ const items = boards.flatMap((board) =>
349
+ board.items
350
+ .filter((item) => ['none', 'stale'].includes(item.verdict))
351
+ .map((item) => ({ ...item, noun: board.noun })),
352
+ );
353
+ return {
354
+ questions,
355
+ open: questions.filter((question) => !question.answered),
356
+ boards,
357
+ items,
358
+ approved: boards.reduce((sum, board) => sum + board.approved, 0),
359
+ total: boards.reduce((sum, board) => sum + board.total, 0),
360
+ };
361
+ };
362
+ const nounCount = (items) => {
363
+ const byNoun = {};
364
+ items.forEach((item) => (byNoun[item.noun] = (byNoun[item.noun] ?? 0) + 1));
365
+ return Object.entries(byNoun).map(([noun, n]) =>
366
+ plural(n, noun === 'rutas' ? 'ruta' : 'endpoint', noun),
367
+ );
368
+ };
369
+
370
+ function boutCard(bout, pending) {
371
+ const scale = ring.scales[bout.pr];
372
+ const red = !scale?.green;
373
+ const showAll = expanded.has(bout.pr);
374
+ const questions = showAll ? pending.open : pending.open.slice(0, 1);
375
+ const items = showAll ? pending.items : pending.items.slice(0, 2);
376
+ const restQuestions = pending.open.length - questions.length;
377
+ const restItems = pending.items.length - items.length;
378
+ const answered = pending.questions.length - pending.open.length;
379
+ const nothing = !pending.open.length && !pending.items.length;
380
+ bout.node.className = `now-bout${nothing ? ' clear' : ''}`;
381
+ put(bout.node,
382
+ el(
383
+ 'div',
384
+ { class: 'now-bout-head' },
385
+ el(
386
+ 'h3',
387
+ {},
388
+ el('a', {
389
+ href: '#',
390
+ text: `#${bout.pr}`,
391
+ onclick: (event) => {
392
+ event.preventDefault();
393
+ const target = boutTarget(bout.pr);
394
+ if (target) reveal(target);
395
+ },
396
+ }),
397
+ ` · ${bout.label}`,
398
+ ),
399
+ pending.open.length
400
+ ? el('span', {
401
+ class: 'now-of',
402
+ text: `pregunta ${answered + 1} de ${pending.questions.length}`,
403
+ })
404
+ : null,
405
+ scale
406
+ ? el('span', {
407
+ class: `now-scale ${red ? 'red' : 'green'}`,
408
+ text: `${red ? 'No da el peso' : 'Da el peso'} · ${scale.approved}/${scale.total}`,
409
+ })
410
+ : null,
411
+ ),
412
+ bout.frozen
413
+ ? el('p', { class: 'now-frozen', text: `Congelada: ${bout.frozen}` })
414
+ : null,
415
+ nothing
416
+ ? el('p', {
417
+ class: 'now-none',
418
+ text:
419
+ scale &&
420
+ !scale.green &&
421
+ scale.total &&
422
+ scale.approved === scale.total
423
+ ? 'Todo aprobado en la página; falta que apruebes el contrato con el réferi (approve-contract).'
424
+ : 'Nada pendiente de ti en esta pelea.',
425
+ })
426
+ : null,
427
+ questions.map(questionForm),
428
+ pending.boards.length && items.length
429
+ ? el('h4', {
430
+ class: 'now-sub',
431
+ text: `Veredictos · ${pending.approved} de ${pending.total}`,
432
+ })
433
+ : null,
434
+ items.map(verdictRow),
435
+ restQuestions || restItems || showAll
436
+ ? el(
437
+ 'button',
438
+ {
439
+ type: 'button',
440
+ class: 'now-queue',
441
+ 'aria-expanded': showAll ? 'true' : 'false',
442
+ onclick: () => {
443
+ if (showAll) expanded.delete(bout.pr);
444
+ else expanded.add(bout.pr);
445
+ paint();
446
+ },
447
+ },
448
+ el('span', { class: 'chev', text: showAll ? '▾' : '▸' }),
449
+ el('b', {
450
+ text: showAll
451
+ ? 'Ocultar la cola'
452
+ : `Siguientes ${list(
453
+ [
454
+ restQuestions
455
+ ? plural(restQuestions, 'pregunta', 'preguntas')
456
+ : '',
457
+ ...nounCount(pending.items.slice(items.length)),
458
+ ].filter(Boolean),
459
+ )}`,
460
+ }),
461
+ showAll ? null : el('span', { class: 'muted', text: 'en la cola' }),
462
+ el('span', { class: 'go', text: showAll ? '' : 'Ver la cola ▸' }),
463
+ )
464
+ : null,
465
+ bout.notes.length
466
+ ? el('div', { class: 'now-bout-status' }, bout.notes)
467
+ : null,
468
+ );
469
+ }
470
+
471
+ function counter(n, label, sub, tone) {
472
+ return el(
473
+ 'div',
474
+ { class: `now-count ${n ? tone : 'zero'}` },
475
+ el('b', { text: String(n) }),
476
+ el('span', { text: label }),
477
+ el('small', { text: sub }),
478
+ );
479
+ }
480
+
481
+ const recommended = () =>
482
+ [...ring.questions.values()]
483
+ .filter((question) => !question.answered)
484
+ .map((question) => ({
485
+ question,
486
+ option: question.options.find((option) => option.rec),
487
+ }))
488
+ .filter((entry) => entry.option);
489
+
490
+ let dialog = null;
491
+ function confirmAccept() {
492
+ const entries = recommended();
493
+ if (!entries.length) return;
494
+ dialog?.remove();
495
+ const say = el('p', { class: 'status', role: 'status' });
496
+ const boxes = entries.map((entry) =>
497
+ el('input', { type: 'checkbox', checked: true }),
498
+ );
499
+ const go = el('button', { type: 'button', class: 'save' });
500
+ const label = () => {
501
+ const n = boxes.filter((box) => box.checked).length;
502
+ go.textContent = `Guardar ${plural(n, 'respuesta', 'respuestas')}`;
503
+ go.disabled = !n;
504
+ };
505
+ dialog = el(
506
+ 'dialog',
507
+ {
508
+ class: 'ctx-dialog now-confirm',
509
+ 'aria-labelledby': 'now-confirm-title',
510
+ },
511
+ el(
512
+ 'div',
513
+ { class: 'ctx-head' },
514
+ el('h3', {
515
+ id: 'now-confirm-title',
516
+ text: 'Aceptar las recomendaciones del réferi',
517
+ }),
518
+ el('button', {
519
+ type: 'button',
520
+ class: 'ctx-close',
521
+ 'aria-label': 'Cerrar',
522
+ text: '×',
523
+ onclick: () => dialog.close(),
524
+ }),
525
+ ),
526
+ el(
527
+ 'div',
528
+ { class: 'ctx-body' },
529
+ el('p', {
530
+ text: 'Se guardará esta respuesta en cada pregunta marcada, como si la eligieras una por una. Desmarca las que quieras contestar tú.',
531
+ }),
532
+ el(
533
+ 'ul',
534
+ { class: 'now-confirm-list' },
535
+ entries.map(({ question, option }, index) =>
536
+ el(
537
+ 'li',
538
+ {},
539
+ el(
540
+ 'label',
541
+ {},
542
+ boxes[index],
543
+ el(
544
+ 'span',
545
+ {},
546
+ el('b', { text: `#${question.pr} · ${question.title}` }),
547
+ el('br'),
548
+ `${option.key}: ${option.text()}`,
549
+ ),
550
+ ),
551
+ ),
552
+ ),
553
+ ),
554
+ el(
555
+ 'div',
556
+ { class: 'now-row' },
557
+ go,
558
+ el('button', {
559
+ type: 'button',
560
+ class: 'ghost',
561
+ text: 'Cancelar',
562
+ onclick: () => dialog.close(),
563
+ }),
564
+ ),
565
+ say,
566
+ ),
567
+ );
568
+ boxes.forEach((box) => box.addEventListener('change', label));
569
+ label();
570
+ go.addEventListener('click', async () => {
571
+ go.disabled = true;
572
+ let saved = 0;
573
+ const failed = [];
574
+ for (const [index, { question, option }] of entries.entries()) {
575
+ if (!boxes[index].checked) continue;
576
+ say.textContent = `Guardando ${saved + 1}…`;
577
+ try {
578
+ await question.save({ choice: option.key, text: option.text() });
579
+ saved += 1;
580
+ } catch (error) {
581
+ failed.push(`${question.title}: ${error.message}`);
582
+ }
583
+ }
584
+ say.textContent = failed.length
585
+ ? `Guardadas ${saved}; no se guardaron ${failed.length}: ${failed.join(' · ')}`
586
+ : `Guardadas ${saved}.`;
587
+ if (!failed.length) setTimeout(() => dialog.close(), 900);
588
+ else go.disabled = false;
589
+ });
590
+ document.body.append(dialog);
591
+ dialog.addEventListener(
592
+ 'click',
593
+ (event) => event.target === dialog && dialog.close(),
594
+ );
595
+ dialog.showModal();
596
+ }
597
+
598
+ let queued = false;
599
+ function paint() {
600
+ queued = false;
601
+ const perBout = bouts.map((bout) => ({
602
+ bout,
603
+ pending: pendingOf(bout.pr),
604
+ }));
605
+ const open = perBout.flatMap(({ pending }) => pending.open);
606
+ const items = perBout.flatMap(({ pending }) => pending.items);
607
+ const red = bouts.filter(
608
+ (bout) => ring.scales[bout.pr] && !ring.scales[bout.pr].green,
609
+ );
610
+ const frozen = bouts.filter((bout) => bout.frozen);
611
+ const busy = open.length + items.length > 0;
612
+ panel.classList.toggle('idle', !busy);
613
+ const oldest = open
614
+ .map((question) => question.time)
615
+ .filter(Boolean)
616
+ .sort()[0];
617
+ put(head,
618
+ el(
619
+ 'div',
620
+ {},
621
+ el('span', {
622
+ class: 'now-kicker',
623
+ text: busy ? 'Tu turno, juez' : 'Sin pendientes del juez',
624
+ }),
625
+ el('h2', {
626
+ text: busy ? 'Qué necesita de ti ahora' : 'Nada que pedirte ahora',
627
+ }),
628
+ ),
629
+ busy
630
+ ? el(
631
+ 'div',
632
+ { class: 'now-wait' },
633
+ oldest
634
+ ? el('span', { text: `el réferi espera desde las ${oldest}` })
635
+ : null,
636
+ open.length
637
+ ? el('em', {
638
+ text: 'sin tu respuesta, el luchador no recibe rebote',
639
+ })
640
+ : null,
641
+ )
642
+ : null,
643
+ );
644
+ const byBout = (pick) =>
645
+ perBout
646
+ .filter(({ pending }) => pick(pending).length)
647
+ .map(
648
+ ({ bout, pending }) =>
649
+ `${pick(pending).length} en ${bout.label.toLowerCase()}`,
650
+ );
651
+ put(counters,
652
+ counter(
653
+ open.length,
654
+ 'Preguntas sin respuesta',
655
+ list(byBout((p) => p.open)) || 'ninguna',
656
+ 'red',
657
+ ),
658
+ counter(
659
+ items.length,
660
+ 'Veredictos pendientes',
661
+ list(nounCount(items)) || 'ninguno',
662
+ 'red',
663
+ ),
664
+ counter(
665
+ red.length,
666
+ 'Básculas en rojo',
667
+ red.length
668
+ ? `no da el peso: ${list(red.map((bout) => `#${bout.pr}`))}`
669
+ : 'todas dan el peso',
670
+ 'gold',
671
+ ),
672
+ counter(
673
+ frozen.length,
674
+ frozen.length === 1 ? 'Pelea congelada' : 'Peleas congeladas',
675
+ frozen.length
676
+ ? frozen.map((bout) => `#${bout.pr} ${bout.frozen}`).join(' · ')
677
+ : 'ninguna',
678
+ 'grey',
679
+ ),
680
+ );
681
+ perBout.forEach(({ bout, pending }) => boutCard(bout, pending));
682
+ const accept = recommended();
683
+ put(actions,
684
+ accept.length
685
+ ? el('button', {
686
+ type: 'button',
687
+ class: 'now-accept',
688
+ text: `✓ Aceptar ${accept.length === 1 ? 'la recomendación' : `las ${accept.length} recomendaciones`} del réferi`,
689
+ onclick: confirmAccept,
690
+ })
691
+ : null,
692
+ open.length
693
+ ? el('button', {
694
+ type: 'button',
695
+ class: 'now-one',
696
+ text: 'Revisar una por una',
697
+ onclick: () => {
698
+ const first = panel.querySelector('.now-q input[type="radio"]');
699
+ first
700
+ ?.closest('.now-q')
701
+ .scrollIntoView({ behavior: 'smooth', block: 'center' });
702
+ first?.focus();
703
+ },
704
+ })
705
+ : null,
706
+ open.length
707
+ ? el('span', { text: 'cada respuesta vuelve al réferi como rebote' })
708
+ : null,
709
+ );
710
+ actions.hidden = !actions.childElementCount;
711
+ }
712
+ const schedule = () => {
713
+ if (queued) return;
714
+ queued = true;
715
+ setTimeout(paint, 0);
716
+ };
717
+ document.addEventListener('ring:change', schedule);
718
+ paint();
719
+ })();