-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathpoll.js
More file actions
2666 lines (2490 loc) · 97.1 KB
/
Copy pathpoll.js
File metadata and controls
2666 lines (2490 loc) · 97.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Standalone live polling app.
*
* Lives next to quiz.js but never imports from it. Uses the same WebSocket
* server protocol (create_room / join / start_question / submit_answer /
* send_results / terminate) so the server stays untouched. Poll-specific
* metadata that the server's whitelist would otherwise drop (picksPerVoter,
* revealCount, source) is smuggled as the first element of `options` — a
* sentinel string `__POLL_META__:{...}` that players strip before display.
*
* Two poll variants share one flow:
* - source: "players" — options are current room players' names (snapshotted
* at vote-start). For Klassensprecher elections, party feedback polls.
* - source: "fixed" — options are host-typed strings. For surveys,
* excursion-location votes, etc.
*
* Both use ranked Borda aggregation. No points awarded. Top-`revealCount`
* podium reveal (scales from 3-slot up to a full ranked list).
*/
/* ============================================================================
* WebSocket URL — same convention as quiz.js (build-time placeholder,
* runtime override, production fallback).
* ============================================================================ */
const RAW_URL = '__WS_URL__';
const FALLBACK_WS_URL = RAW_URL === '__WS_URL__' ? 'wss://qlash-server.fly.dev' : RAW_URL;
const HAS_RUNTIME_WS_URL =
globalThis.window !== undefined && globalThis.WS_URL && globalThis.WS_URL !== '__WS_URL__';
const WS_URL = HAS_RUNTIME_WS_URL ? globalThis.WS_URL : FALLBACK_WS_URL;
/* ============================================================================
* Constants
* ============================================================================ */
// First options[] slot carries this prefixed JSON; server's per-option 500-char
// validation gives us plenty of headroom for {picksPerVoter, revealCount, source}.
const POLL_META_PREFIX = '__POLL_META__:';
// Server caps options.length at MAX_OPTIONS_PER_QUESTION (250). Slot 0 is the
// metadata sentinel, leaving 249 real options — comfortably above
// MAX_PLAYERS_PER_ROOM (240) so a full-room Klassensprecher poll fits.
const MAX_REAL_OPTIONS = 240;
const MAX_PICKS_PER_VOTER = 20;
const MIN_DURATION_SEC = 5;
const MAX_DURATION_SEC = 80;
const SESSION_STORAGE_KEY = 'poll_active_session';
const PICKS_STORAGE_KEY = 'poll_last_picks';
// 24 h matches quiz.js. The server-side room expiry (2 h) is the real ceiling;
// this TTL is just for stale-entry cleanup if the user never returns. A poll
// that's been gone longer than this is no longer recoverable anyway.
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_RECONNECT_ATTEMPTS = 30;
// Application-level heartbeat. The server pings every 30 s at the WebSocket
// protocol level and the browser auto-pongs, but (a) some intermediate proxies
// (carrier NAT, corporate firewalls) only see app-layer frames as "activity"
// and drop the TCP socket after ~60 s of silence, and (b) if the server's
// network silently goes dark without closing the TCP connection, the browser
// may keep the socket in OPEN state for many seconds before noticing.
//
// We send a `{type:'heartbeat'}` every 25 s; the server replies with
// `{type:'heartbeat_ack'}`. A watchdog on the client side checks how long ago
// we last heard *anything* from the server (heartbeat ack, game message, etc.)
// — if > HEARTBEAT_TIMEOUT_MS, we force-close the socket and let the unified
// reconnect logic take over.
const HEARTBEAT_INTERVAL_MS = 25_000;
const HEARTBEAT_TIMEOUT_MS = 60_000;
const HEARTBEAT_WATCHDOG_INTERVAL_MS = 10_000;
const HEARTBEAT_PAYLOAD = JSON.stringify({ type: 'heartbeat' });
/* ============================================================================
* Tiny helpers
* ============================================================================ */
/**
* Toast notification. Same visual pattern as quiz.js so the two apps feel
* kindred; duplicated here per the no-touch-quiz directive.
* @param {string} message
* @param {'info'|'error'} [type]
*/
function showMessage(message, type = 'info') {
const existing = document.querySelector('#toast-notification');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.id = 'toast-notification';
toast.className = `toast-notification toast-${type}`;
// Announce to assistive tech: errors assertively, everything else politely.
toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
toast.textContent = message;
document.body.append(toast);
requestAnimationFrame(() => toast.classList.add('show'));
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => {
if (toast.parentNode) toast.remove();
}, 300);
}, 4000);
}
/**
* Accessible modal dialog — a themed, focus-trapped replacement for the native
* blocking `confirm()`. Resolves to `true` (confirmed) / `false` (cancelled).
* Esc and backdrop click cancel; Enter on the focused confirm button confirms;
* focus is trapped while open and restored on close.
* @param {object} opts
* @param {string} opts.message
* @param {string} [opts.confirmText]
* @param {string} [opts.cancelText]
* @param {boolean} [opts.danger] - Style the confirm button as destructive.
* @returns {Promise<boolean>}
*/
function uiDialog(opts) {
const { message, confirmText = 'OK', cancelText = 'Abbrechen', danger = false } = opts;
return new Promise((resolve) => {
const previouslyFocused = document.activeElement;
const backdrop = document.createElement('div');
backdrop.className = 'ui-modal-backdrop';
const modal = document.createElement('div');
modal.className = 'ui-modal';
modal.setAttribute('role', 'alertdialog');
modal.setAttribute('aria-modal', 'true');
const msgEl = document.createElement('p');
msgEl.className = 'ui-modal-message';
msgEl.id = `ui-modal-msg-${Date.now()}`;
msgEl.textContent = message;
modal.setAttribute('aria-labelledby', msgEl.id);
modal.append(msgEl);
const actions = document.createElement('div');
actions.className = 'ui-modal-actions';
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'ui-modal-btn ui-modal-cancel';
cancelBtn.textContent = cancelText;
const confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.className = `ui-modal-btn ui-modal-confirm${danger ? ' ui-modal-danger' : ''}`;
confirmBtn.textContent = confirmText;
actions.append(cancelBtn, confirmBtn);
modal.append(actions);
backdrop.append(modal);
document.body.append(backdrop);
const prevBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
let settled = false;
/**
* @param {boolean} result
*/
function close(result) {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKeydown, true);
document.body.style.overflow = prevBodyOverflow;
backdrop.remove();
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
previouslyFocused.focus();
}
resolve(result);
}
/**
* @param {KeyboardEvent} e
*/
function onKeydown(e) {
if (e.key === 'Escape') {
e.preventDefault();
close(false);
} else if (e.key === 'Tab') {
const first = cancelBtn;
const last = confirmBtn;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
cancelBtn.addEventListener('click', () => close(false));
confirmBtn.addEventListener('click', () => close(true));
backdrop.addEventListener('mousedown', (e) => {
if (e.target === backdrop) close(false);
});
document.addEventListener('keydown', onKeydown, true);
confirmBtn.focus();
});
}
/**
* Themed confirm dialog. @see uiDialog
* @param {string} message
* @param {object} [options]
* @returns {Promise<boolean>}
*/
function uiConfirm(message, options = {}) {
return uiDialog({ ...options, message });
}
/**
* Show a top-level view (role-selection / host-view / player-view) and hide
* the others. View IDs match the markup in poll.html.
* @param {string} id
*/
function showTopView(id) {
for (const v of document.querySelectorAll('.view')) v.classList.remove('active');
const el = document.querySelector(`#${CSS.escape(id)}`);
if (el) el.classList.add('active');
}
/**
* Within host-view or player-view, show one sub-section and hide its siblings.
* @param {string[]} allIds
* @param {string} showId
*/
function showOnly(allIds, showId) {
for (const id of allIds) {
const el = document.querySelector(`#${CSS.escape(id)}`);
if (!el) continue;
el.classList.toggle('hidden', id !== showId);
}
}
/**
* WebSocket connect with retry — mirrors quiz.js's connectWithRetry to handle
* Fly.io cold starts.
* @param {string} url
* @param {number} [maxRetries]
* @returns {Promise<WebSocket>}
*/
function connectWithRetry(url, maxRetries = 3) {
return new Promise((resolve, reject) => {
let attempt = 0;
function tryConnect() {
attempt++;
const ws = new WebSocket(url);
let settled = false;
// Listener refs so we can detach the unused one on settle.
let onOpen;
let onError;
const detach = () => {
ws.removeEventListener('open', onOpen);
ws.removeEventListener('error', onError);
};
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
detach();
ws.close();
if (attempt < maxRetries) {
setTimeout(tryConnect, 2000 * attempt);
} else {
reject(new Error('WebSocket connection failed after retries'));
}
}, 10_000);
onOpen = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
detach();
resolve(ws);
};
onError = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
detach();
if (attempt < maxRetries) {
setTimeout(tryConnect, 2000 * attempt);
} else {
reject(new Error('WebSocket connection failed after retries'));
}
};
ws.addEventListener('open', onOpen);
ws.addEventListener('error', onError);
}
tryConnect();
});
}
/**
* ~1 s → ~2 s → ~4 s → ~10 s steady — same backoff as quiz.js. ±25% jitter is
* applied so a cascade event (e.g. a brief router restart that drops every
* client at once) doesn't produce a thundering herd hitting the server on the
* same backoff schedule.
* @param {number} attempt
* @returns {number}
*/
function reconnectBackoffMs(attempt) {
let base;
if (attempt <= 1) base = 1000;
else if (attempt === 2) base = 2000;
else if (attempt === 3) base = 4000;
else base = 10_000;
const jitter = 0.75 + Math.random() * 0.5;
return Math.round(base * jitter);
}
/**
* Start the bidirectional heartbeat for a WebSocket. Returns a state object
* holding the rescheduling timer, the watchdog interval, and the last-seen /
* last-sent timestamps. Pass the state to `stopHeartbeat` when the socket
* closes.
*
* `ws.send` is wrapped so that *any* outbound frame (submit_answer,
* start_question, heartbeat itself, ...) reschedules the next heartbeat for
* exactly `HEARTBEAT_INTERVAL_MS` from now. This guarantees the gap between
* any two outbound frames is at most `HEARTBEAT_INTERVAL_MS` — important for
* NATs that drop idle TCP after as little as 30 s. A simple periodic
* `setInterval` skip-if-recent would have a worst-case gap of ~2× the
* interval (~50 s) when a real send lands just after a tick fires.
*
* `state.lastMsgTime` is updated externally (by the message-handler attached
* in attachXWsHandlers) on every received frame, so an active stream of
* server-pushed messages keeps the watchdog quiet without a redundant
* heartbeat round-trip.
* @param {WebSocket} ws
* @returns {{heartbeatTimer:number, watchdog:number, lastMsgTime:number, lastSendTime:number}}
*/
function startHeartbeat(ws) {
const now = Date.now();
const state = { lastMsgTime: now, lastSendTime: now, heartbeatTimer: null };
function scheduleNextHeartbeat() {
if (state.heartbeatTimer !== null) clearTimeout(state.heartbeatTimer);
state.heartbeatTimer = setTimeout(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
try {
// Goes through the wrapped send, which itself reschedules.
ws.send(HEARTBEAT_PAYLOAD);
} catch (error) {
logger.error('Heartbeat send failed:', error);
}
}, HEARTBEAT_INTERVAL_MS);
}
// Wrap ws.send so every outbound frame bumps lastSendTime *and* resets
// the heartbeat timer. Stash the native bound send on the socket itself
// so a second startHeartbeat call on the same socket doesn't capture
// the previous wrapper as "original" and stack wrappers (which would
// self-reschedule recursively on each outbound frame).
if (!ws.__heartbeatNativeSend) {
ws.__heartbeatNativeSend = ws.send.bind(ws);
}
const originalSend = ws.__heartbeatNativeSend;
ws.send = (...args) => {
state.lastSendTime = Date.now();
scheduleNextHeartbeat();
return originalSend(...args);
};
// Kick off the first heartbeat 25 s from now (initial state).
scheduleNextHeartbeat();
state.watchdog = setInterval(() => {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const silentMs = Date.now() - state.lastMsgTime;
if (silentMs > HEARTBEAT_TIMEOUT_MS) {
logger.warn(
`No server activity for ${Math.round(silentMs / 1000)}s — forcing reconnect.`
);
try {
ws.close();
} catch {
/* close already in progress */
}
}
}, HEARTBEAT_WATCHDOG_INTERVAL_MS);
return state;
}
/**
* @param {{heartbeatTimer:number|null, watchdog:number}|null} state
* @returns {null}
*/
function stopHeartbeat(state) {
if (state) {
if (state.heartbeatTimer !== null) clearTimeout(state.heartbeatTimer);
clearInterval(state.watchdog);
}
return null;
}
/**
* Session save/load/clear so a page reload mid-poll can re-attach without
* re-entering a code.
* @param role
* @param roomId
* @param sessionId
* @param extra
*/
function saveActiveSession(role, roomId, sessionId, extra = {}) {
try {
localStorage.setItem(
SESSION_STORAGE_KEY,
JSON.stringify({ role, roomId, sessionId, ts: Date.now(), ...extra })
);
} catch {
/* private mode */
}
}
function loadActiveSession() {
try {
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || !parsed.role || !parsed.roomId || !parsed.sessionId) return null;
if (parsed.ts && Date.now() - parsed.ts > SESSION_TTL_MS) {
localStorage.removeItem(SESSION_STORAGE_KEY);
return null;
}
return parsed;
} catch {
return null;
}
}
function clearActiveSession() {
try {
localStorage.removeItem(SESSION_STORAGE_KEY);
} catch {
/* private mode */
}
}
/**
* Last-submitted picks: keep them across reconnect so the player can still
* see their own ballot on the reveal even if they were disconnected when the
* server replayed `alreadySubmitted: true`.
* @param roomId
* @param questionKey
* @param picks
*/
function saveLastPicks(roomId, questionKey, picks) {
try {
sessionStorage.setItem(PICKS_STORAGE_KEY, JSON.stringify({ roomId, questionKey, picks }));
} catch {
/* private mode */
}
}
function loadLastPicks(roomId, questionKey) {
try {
const raw = sessionStorage.getItem(PICKS_STORAGE_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (p && p.roomId === roomId && p.questionKey === questionKey) return p.picks;
return null;
} catch {
return null;
}
}
function clearLastPicks() {
try {
sessionStorage.removeItem(PICKS_STORAGE_KEY);
} catch {
/* private mode */
}
}
/* ============================================================================
* Metadata encoding
*
* options[0] = '__POLL_META__:' + JSON.stringify({picksPerVoter, revealCount,
* source}); options[1..N] are the displayable choices. Players strip slot 0
* before rendering; their submitted indices refer to slot positions in the
* full options array (so a "first real option" submission is index 1, not 0).
* The host computes Borda over those same indices.
* ============================================================================ */
/**
* @param {{picksPerVoter:number, revealCount:number|'all', source:'players'|'fixed'}} meta
* @returns {string}
*/
function encodeMeta(meta) {
return POLL_META_PREFIX + JSON.stringify(meta);
}
/**
* @param {string} optionZero
* @returns {{picksPerVoter:number, revealCount:number|'all', source:string}|null}
*/
function decodeMeta(optionZero) {
if (typeof optionZero !== 'string' || !optionZero.startsWith(POLL_META_PREFIX)) {
return null;
}
try {
const obj = JSON.parse(optionZero.slice(POLL_META_PREFIX.length));
if (!obj || typeof obj !== 'object') return null;
const picks = Number(obj.picksPerVoter);
const reveal = obj.revealCount === 'all' ? 'all' : Number(obj.revealCount);
const source = obj.source === 'fixed' ? 'fixed' : 'players';
if (!Number.isFinite(picks) || picks < 1) return null;
if (reveal !== 'all' && (!Number.isFinite(reveal) || reveal < 1)) return null;
return { picksPerVoter: picks, revealCount: reveal, source };
} catch {
return null;
}
}
/* ============================================================================
* Shared module state
* ============================================================================ */
// Host state
let hostWs = null;
let hostRoomId = null;
let hostSessionId = null;
let hostWsReconnectAttempts = 0;
let hostSuppressReconnect = false;
// True while a reconnect attempt is in flight (awaiting connectWithRetry).
// Prevents the close handler, the `online` event, and a manual button click
// from racing into multiple parallel WebSocket connections.
let hostReconnecting = false;
let hostHeartbeat = null;
// players keyed by sessionId; value: { name, isConnected }
const hostPlayers = new Map();
// During an active poll: snapshot of the broadcast question payload + answers.
let hostActivePoll = null; // {question, options, picksPerVoter, revealCount, source, duration}
// Guard so endVote() can be triggered from multiple sources (timer, auto-end,
// host click) without re-sending results.
let hostPollEnding = false;
const hostAnswers = new Map(); // sessionId -> {ranks: number[], name}
let hostTimerInterval = null;
// Pending question to send once a fresh hostWs is open after a transient drop.
let hostPendingQuestion = null;
// Pending results frame to send once a fresh hostWs is open after a transient
// drop. Without this, an endVote() that ran while the host WS was closed
// would compute the podium locally but never notify the players — they'd be
// stuck on the voting screen until reconnect or manual session end.
let hostPendingResults = null;
// Grace timer that defers maybeAutoEndVote when a disconnect makes the
// remaining connected players look all-voted. Lets a reloading voter rejoin
// before the vote closes around them.
let hostDisconnectGraceTimeout = null;
const DISCONNECT_GRACE_MS = 10_000;
// Player state
let playerWs = null;
let playerRoomCode = null;
let playerSessionId = null;
let playerName = null;
let playerWsReconnectAttempts = 0;
let playerSuppressReconnect = false;
// See hostReconnecting — same purpose.
let playerReconnecting = false;
let playerHeartbeat = null;
let playerCurrentMeta = null;
let playerDisplayOptions = []; // options[1..N] (sliced)
let playerOptionIndexBase = 1; // index of the first displayable option in raw options
let playerCurrentQuestion = ''; // raw question text
let playerSelectedRanks = []; // indices into raw options
let playerHasSubmitted = false;
let playerTimerInterval = null;
let playerCurrentQuestionKey = ''; // stable identifier for "this question"
// Composer state for fixed source
const composerFixedOptions = ['', ''];
// Built-in question suggestions for the host — class-rep-style "Wer …" awards.
// All are source: "players" polls (voting for someone in the room). The host
// can always type their own question instead; suggestions are unobtrusive.
const POLL_SUGGESTIONS = [
{
title: 'Mebis-Sherpa',
question:
'Mebis-Sherpa: Wer hat die eigene Gruppe durch die tiefsten Mebis-Kapitel gecarried und alle mit Lösungen versorgt?',
},
{
title: 'Koffein-Reaktor',
question:
'Koffein-Reaktor: Wer besteht nach drei Jahren eigentlich zu 80 % aus Energydrinks und Mate statt aus Wasser?',
},
{
title: 'Linux-Prediger*in',
question:
'Linux-Prediger*in: Wer lässt keine Gelegenheit aus zu betonen, dass Windows Müll ist und Arch das einzig Wahre ist? (I use Arch, btw)',
},
{
title: 'Git-Poet*in',
question:
'Git-Poet*in: Wer schreibt die wildesten Commit-Messages? (z. B. "update", "fix", "asdasd", "jetzt gehts")',
},
{
title: 'Stealth-Gamer*in',
question:
'Stealth-Gamer*in: Wer hat im Unterricht völlig unbemerkt hunderte Stunden in Games versenkt?',
},
{
title: 'Zukünftige*r Start-up-Milliardär*in',
question:
'Zukünftige*r Start-up-Milliardär*in: Wer gründet als Erstes ein Start-up für ein überkomplexes Gadget, das absolut niemand braucht?',
},
{
title: 'Chief Hydration Officer (CHO)',
question:
'Chief Hydration Officer (CHO): Wer hat heldenhaft die Klasse am Wasserspender versorgt und vor dem Dehydrieren bewahrt?',
},
{
title: 'ChatGPT-Magier*in',
question:
'ChatGPT-Magier*in: Wer hat die gesamte schulische Existenz aus perfekten Prompts zusammengeklebt – und es hat funktioniert?',
},
{
title: 'Bullshit-Bingo-Legende',
question:
'Bullshit-Bingo-Legende: Schlecht gelernt, perfekt verkauft: Wer referiert souverän über Themen, die erst 5 Minuten vorher gegoogelt wurden?',
},
{
title: 'Heimliche*r 1st-Level-Support',
question:
'Heimliche*r 1st-Level-Support: Wer ist die unfreiwillige Dauer-Hotline für die ByCS- und WLAN-Probleme der kompletten Klasse?',
},
{
title: 'Berichtsheft-Pedant*in',
question:
'Berichtsheft-Pedant*in: Wer führt das Berichtsheft wie ein Git-Repo – mit fix:-Einträgen und einem Branch pro Quartal?',
},
{
title: 'IHK-Stoiker*in',
question:
'IHK-Stoiker*in: Wer hat in der Abschlussprüfung eiskalt vier Sekunden vor Abgabe das letzte Kreuz gesetzt?',
},
{
title: 'Hardware-Jünger*in',
question:
'Hardware-Jünger*in: Wer investiert das allererste richtige Gehalt sofort in eine RTX 5090?',
},
{
title: 'Meme-Beauftragte*r',
question:
'Meme-Beauftragte*r: Wer postet im Klassenchat deutlich mehr Memes und :wq-Jokes als ernsthafte Nachrichten?',
},
{
title: 'Open-Source-Philanthrop*in',
question:
'Open-Source-Philanthrop*in: Wer hat während des Unterrichts heimlich Bug-Bounties gesammelt oder Pull Requests eingereicht?',
},
];
// Cycle state: the host walks a single shuffled order of all suggestions
// (randomized once per session, then deterministic). A substring filter from
// the question textarea is applied on top — cycling and auto-pick stay within
// matching suggestions. When the typed text matches nothing, the filter is
// ignored so a suggestion still shows (same as an empty input).
let suggestionOrder = [];
let suggestionCursor = 0;
let currentSuggestionIdx = null;
/* ============================================================================
* DOM references — populated on DOMContentLoaded.
* ============================================================================ */
let dom = {};
/**
*
*/
function collectDom() {
dom = {
roleSelection: document.querySelector('#role-selection'),
hostBtn: document.querySelector('#host-btn'),
playerBtn: document.querySelector('#player-btn'),
reconnectHostBtn: document.querySelector('#reconnect-host-btn'),
reconnectPlayerBtn: document.querySelector('#reconnect-player-btn'),
hostView: document.querySelector('#host-view'),
hostLobby: document.querySelector('#host-lobby'),
hostComposer: document.querySelector('#host-composer'),
hostVoting: document.querySelector('#host-voting'),
hostReveal: document.querySelector('#host-reveal'),
playerView: document.querySelector('#player-view'),
joinForm: document.querySelector('#join-form'),
playerWaiting: document.querySelector('#player-waiting'),
playerVote: document.querySelector('#player-vote'),
playerSubmitted: document.querySelector('#player-submitted'),
playerReveal: document.querySelector('#player-reveal'),
// Lobby
qrcodeEl: document.querySelector('#qrcode'),
joinLink: document.querySelector('#join-link'),
roomIdEl: document.querySelector('#room-id'),
playerCountEl: document.querySelector('#player-count'),
playersListEl: document.querySelector('#players-list'),
showComposerBtn: document.querySelector('#show-composer-btn'),
// Composer
composerQuestion: document.querySelector('#composer-question'),
suggestionBox: document.querySelector('#suggestion-box'),
suggestionTitle: document.querySelector('#suggestion-title'),
suggestionText: document.querySelector('#suggestion-text'),
applySuggestionBtn: document.querySelector('#apply-suggestion-btn'),
cycleSuggestionBtn: document.querySelector('#cycle-suggestion-btn'),
sourceRadios: document.querySelectorAll('input[name="poll-source"]'),
fixedOptionsSection: document.querySelector('#fixed-options-section'),
composerOptionsList: document.querySelector('#composer-options'),
addOptionBtn: document.querySelector('#add-option-btn'),
composerPicks: document.querySelector('#composer-picks'),
composerReveal: document.querySelector('#composer-reveal'),
composerRevealAll: document.querySelector('#composer-reveal-all'),
composerDuration: document.querySelector('#composer-duration'),
startVoteBtn: document.querySelector('#start-vote-btn'),
backToLobbyBtn: document.querySelector('#back-to-lobby-btn'),
// Voting (host)
hostQuestionDisplay: document.querySelector('#host-question-display'),
hostTimerBar: document.querySelector('#host-timer-bar'),
hostAnswersCount: document.querySelector('#host-answers-count'),
hostTotalPlayers: document.querySelector('#host-total-players'),
hostLiveOptions: document.querySelector('#host-live-options'),
endVoteBtn: document.querySelector('#end-vote-btn'),
// Reveal (host)
hostRevealQuestion: document.querySelector('#host-reveal-question'),
hostPodiumList: document.querySelector('#host-podium-list'),
hostRevealMeta: document.querySelector('#host-reveal-meta'),
nextQuestionBtn: document.querySelector('#next-question-btn'),
endSessionBtn: document.querySelector('#end-session-btn'),
// Player
roomCodeInput: document.querySelector('#room-code-input'),
playerNameInput: document.querySelector('#player-name-input'),
joinBtn: document.querySelector('#join-btn'),
playerWaitingStatus: document.querySelector('#player-waiting-status'),
playerNameDisplay: document.querySelector('#player-name-display'),
playerQuestionText: document.querySelector('#player-question-text'),
playerTimerBar: document.querySelector('#player-timer-bar'),
playerVoteHint: document.querySelector('#player-vote-hint'),
rankList: document.querySelector('#rank-list'),
rankListEmpty: document.querySelector('#rank-list-empty'),
rankOptions: document.querySelector('#rank-options'),
submitVoteBtn: document.querySelector('#submit-vote-btn'),
submittedPicksEcho: document.querySelector('#submitted-picks-echo'),
playerRevealQuestion: document.querySelector('#player-reveal-question'),
playerPodiumList: document.querySelector('#player-podium-list'),
playerPicksEcho: document.querySelector('#player-picks-echo'),
playerRevealStatus: document.querySelector('#player-reveal-status'),
// QR modal
qrModalOverlay: document.querySelector('#qr-modal-overlay'),
qrModalClose: document.querySelector('#qr-modal-close'),
largeQrcode: document.querySelector('#large-qrcode'),
joinLinkModal: document.querySelector('#join-link-modal'),
modalRoomIdSpan: document.querySelector('#modal-room-id'),
};
}
/* ============================================================================
* QR code rendering
* ============================================================================ */
/**
* @param {string} roomId
* @returns {string}
*/
function buildJoinUrl(roomId) {
const loc = globalThis.location;
const base = `${loc.protocol}//${loc.host}${loc.pathname}`;
return `${base}?room=${encodeURIComponent(roomId)}`;
}
/**
* @param {string} url
*/
function renderQrCode(url) {
if (!dom.qrcodeEl) return;
dom.qrcodeEl.innerHTML = '';
if (typeof QRCode === 'undefined') return;
try {
new QRCode(dom.qrcodeEl, {
text: url,
width: 180,
height: 180,
correctLevel: QRCode.CorrectLevel.H,
});
} catch (error) {
logger.error('QR generation failed:', error);
}
}
/**
* @param {string} url
*/
function renderLargeQrCode(url) {
if (!dom.largeQrcode) return;
dom.largeQrcode.innerHTML = '';
if (typeof QRCode === 'undefined') return;
try {
new QRCode(dom.largeQrcode, {
text: url,
width: 360,
height: 360,
correctLevel: QRCode.CorrectLevel.H,
});
} catch (error) {
logger.error('QR generation failed:', error);
}
}
/* ============================================================================
* Host: lifecycle
* ============================================================================ */
/**
* Open a fresh room. Called when user clicks "Umfrage hosten".
*/
async function initHost() {
showTopView('host-view');
showOnly(['host-lobby', 'host-composer', 'host-voting', 'host-reveal'], 'host-lobby');
hostWsReconnectAttempts = 0;
hostSuppressReconnect = false;
try {
hostWs = await connectWithRetry(WS_URL);
} catch {
showMessage('Server nicht erreichbar. Bitte versuche es später erneut.', 'error');
return;
}
attachHostWsHandlers(hostWs);
hostWs.send(JSON.stringify({ type: 'create_room' }));
}
/**
* Re-attach a previous host session after a page reload. Routes through the
* unified reconnect logic so transient network failures keep retrying
* instead of stranding the user. The active session is preserved on
* failure so the reconnect button stays available for another try.
* @param {{roomId:string, sessionId:string}} info
*/
function initHostReconnect(info) {
hostRoomId = info.roomId;
hostSessionId = info.sessionId;
showTopView('host-view');
showOnly(['host-lobby', 'host-composer', 'host-voting', 'host-reveal'], 'host-lobby');
hostWsReconnectAttempts = 0;
hostSuppressReconnect = false;
reconnectHostWs();
}
/**
* @param {WebSocket} ws
*/
function attachHostWsHandlers(ws) {
ws.addEventListener('message', (ev) => {
// Any incoming frame (heartbeat_ack or real game message) counts as
// server liveness — keeps the watchdog quiet.
if (hostHeartbeat) hostHeartbeat.lastMsgTime = Date.now();
let msg;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
handleHostMessage(msg);
});
hostHeartbeat = stopHeartbeat(hostHeartbeat);
hostHeartbeat = startHeartbeat(ws);
ws.addEventListener('close', () => {
// Ignore stale close events from a previous WS instance we've
// already replaced — otherwise an old socket's close (which can
// fire seconds after we attached a new one) would tear down the
// healthy current connection's keepalive and schedule a redundant
// reconnect on top.
if (ws !== hostWs) return;
logger.log('Host WS closed');
hostHeartbeat = stopHeartbeat(hostHeartbeat);
reconnectHostWs();
});
ws.addEventListener('error', (err) => {
logger.error('Host WS error:', err);
});
}
/**
* Single source of truth for reconnect attempts. Called from the close
* handler, the `online` event, and the manual reconnect button — guards
* keep concurrent invocations from racing into multiple sockets, and a
* failed connect schedules another attempt instead of giving up (which
* previously stranded users when a transient network blip exhausted
* connectWithRetry's inner budget on a single outer attempt).
*/
async function reconnectHostWs() {
if (hostReconnecting) return;
if (hostSuppressReconnect) return;
if (!hostRoomId || !hostSessionId) return;
if (hostWs && hostWs.readyState === WebSocket.OPEN) return;
if (hostWsReconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
showMessage('Verbindung zum Server verloren. Bitte lade die Seite neu.', 'error');
return;
}
hostReconnecting = true;
hostWsReconnectAttempts++;
showMessage(
`Verbindung unterbrochen. Reconnect ${hostWsReconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}…`,
'info'
);
let connectedWs;
try {
connectedWs = await connectWithRetry(WS_URL);
} catch {
hostReconnecting = false;
if (hostSuppressReconnect) return;
// Schedule another attempt instead of giving up — this is the
// fix for "reconnect didn't work". A flaky mobile uplink can
// exhaust connectWithRetry's inner ~30 s budget on a single outer
// attempt, but we have plenty of outer budget left.
const delay = reconnectBackoffMs(hostWsReconnectAttempts);
setTimeout(reconnectHostWs, delay);
return;
}
hostWs = connectedWs;
// Clear the in-flight guard before attaching handlers and sending. If
// `ws.send` throws or a synchronous close event fires mid-setup, the
// close handler's reconnectHostWs() call would otherwise early-return
// on the still-true guard and we'd stall.
hostReconnecting = false;
attachHostWsHandlers(hostWs);
hostWs.send(
JSON.stringify({ type: 'reconnect_host', roomId: hostRoomId, sessionId: hostSessionId })
);
// Resend any pending question that wasn't acknowledged across the gap.
if (hostPendingQuestion) {
hostWs.send(JSON.stringify(hostPendingQuestion));
hostPendingQuestion = null;
}
// Same for results — an endVote() that fired during the outage left the
// poll resolved on the host side but never reached players.
if (hostPendingResults) {
hostWs.send(JSON.stringify(hostPendingResults));
hostPendingResults = null;
}
// Counter resets to 0 in the 'host_reconnected' message handler.
}
/**
* @param {object} msg
*/
function handleHostPlayerUpdate(msg) {
switch (msg.type) {
case 'player_joined': {
hostPlayers.set(msg.sessionId, { name: msg.name, isConnected: true });
// If a poll is in progress and it's voting on people in the room,
// append the new joiner's name to the options so others can rank
// them too. Appending (never inserting) keeps previously-submitted
// indices valid.
if (hostActivePoll && hostActivePoll.source === 'players' && !hostPollEnding) {
appendPlayerToActivePoll(msg.name, msg.sessionId);
}
break;
}
case 'player_reconnected': {
// Cancel any pending grace-period auto-end: a reconnect could mean
// the player is back at the voting screen and about to submit.
if (hostDisconnectGraceTimeout) {
clearTimeout(hostDisconnectGraceTimeout);
hostDisconnectGraceTimeout = null;
}
const existing = hostPlayers.get(msg.sessionId);
if (existing) {
existing.isConnected = true;
existing.name = msg.name || existing.name;
} else {
hostPlayers.set(msg.sessionId, { name: msg.name, isConnected: true });
}
// The reconnecting player may not be in the poll's options yet —
// they could have been disconnected at vote-start, or originally
// joined during a host outage and thus never propagated to our
// local poll state. appendPlayerToActivePoll's optionPlayerIds
// check makes this idempotent if they're already represented.
if (hostActivePoll && hostActivePoll.source === 'players' && !hostPollEnding) {
appendPlayerToActivePoll(msg.name, msg.sessionId);
}
break;
}
case 'player_left': {
const existing = hostPlayers.get(msg.sessionId);
if (existing) existing.isConnected = false;
break;
}
// No default
}