PageSourceSearch

https://lingovoice.ai/schedule-ui.js?v=20260729a

js lingovoice.ai collected 2026-10-05 13:07:01 UTC 27,164 bytes, 573 lines download raw bytes

1/**
2 * Scheduled Sessions UI
3 * Loaded after app.js in direct-reference.html
4 * Adds: schedule button, create modal, my sessions panel, upcoming session banner
5 */
6
7(function() {
8    'use strict';
9
10    // ==========================================
11    // HELPERS
12    // ==========================================
13
14    function escapeHtml(str) {
15        if (!str) return '';
16        var div = document.createElement('div');
17        div.textContent = String(str);
18        return div.innerHTML;
19    }
20
21    function getAuthToken() {
22        // LingoAuth is the real token store (lv_access_token) — the old keys
23        // were never written, which made this whole feature claim "signed out".
24        return (window.LingoAuth && LingoAuth.getAccessToken && LingoAuth.getAccessToken())
25            || window.authToken || localStorage.getItem('authToken');
26    }
27
28    function showNotification(message, type) {
29        if (window.showToast) { window.showToast(message, type); return; }
30        var notif = document.createElement('div');
31        var bgColor = type === 'success' ? '#10B981' : type === 'warning' ? '#f59e0b' : '#2563EB';
32        notif.style.cssText = 'position:fixed;top:60px;left:50%;transform:translateX(-50%);background:' + bgColor + ';color:#fff;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;z-index:10002;box-shadow:0 4px 20px rgba(0,0,0,0.3);';
33        notif.textContent = message;
34        document.body.appendChild(notif);
35        setTimeout(function() { notif.remove(); }, 4000);
36    }
37
38    function formatDate(dateStr) {
39        var d = new Date(dateStr);
40        return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
41    }
42
43    function formatTime(dateStr) {
44        var d = new Date(dateStr);
45        return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
46    }
47
48    function timeUntil(dateStr) {
49        var diff = new Date(dateStr).getTime() - Date.now();
50        if (diff <= 0) return 'now';
51        var mins = Math.floor(diff / 60000);
52        if (mins < 60) return mins + 'min';
53        var hrs = Math.floor(mins / 60);
54        if (hrs < 24) return hrs + 'h ' + (mins % 60) + 'min';
55        return Math.floor(hrs / 24) + 'd ' + (hrs % 24) + 'h';
56    }
57
58    // ==========================================
59    // SCHEDULE BUTTON (pre-join screen)
60    // ==========================================
61
62    function injectScheduleButton() {
63        if (document.getElementById('scheduleBtn')) return;
64
65        // Look for the join room form area or pre-room controls.
66        // #roomSetup / #joinBtn are what direct-reference.html actually uses —
67        // the original selectors matched nothing, so the whole Schedule
68        // feature was invisible (2026-06-10 audit).
69        var joinArea = document.querySelector('#roomSetup .setup-card, #roomSetup, .pre-room-actions, .join-form, #joinRoomForm, #preRoomScreen');
70        if (!joinArea) {
71            // Try finding by the join button
72            var joinBtn = document.querySelector('#joinBtn, button[data-action="join"], #joinRoomBtn, .join-room-btn');
73            if (joinBtn) joinArea = joinBtn.parentElement;
74        }
75        if (!joinArea) return;
76
77        var btn = document.createElement('button');
78        btn.id = 'scheduleBtn';
79        // var(--primary-text) is #60A5FA in dark mode and unset in light, so the
80        // fallback gives readable brand blue on the light join screen — the old
81        // hardcoded #60A5FA was a dark-theme colour washing out on white.
82        btn.style.cssText = 'background:rgba(37,99,235,0.08);border:1px solid rgba(37,99,235,0.3);color:var(--primary-text, #2563EB);padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;display:flex;align-items:center;gap:8px;margin-top:8px;width:100%;justify-content:center;transition:all 0.2s;';
83
84        var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
85        svg.setAttribute('width', '16');
86        svg.setAttribute('height', '16');
87        svg.setAttribute('viewBox', '0 0 24 24');
88        svg.setAttribute('fill', 'none');
89        svg.setAttribute('stroke', 'currentColor');
90        svg.setAttribute('stroke-width', '2');
91        var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
92        rect.setAttribute('x', '3');
93        rect.setAttribute('y', '4');
94        rect.setAttribute('width', '18');
95        rect.setAttribute('height', '18');
96        rect.setAttribute('rx', '2');
97        svg.appendChild(rect);
98        var line1 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
99        line1.setAttribute('x1', '16');
100        line1.setAttribute('y1', '2');
101        line1.setAttribute('x2', '16');
102        line1.setAttribute('y2', '6');
103        svg.appendChild(line1);
104        var line2 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
105        line2.setAttribute('x1', '8');
106        line2.setAttribute('y1', '2');
107        line2.setAttribute('x2', '8');
108        line2.setAttribute('y2', '6');
109        svg.appendChild(line2);
110        var line3 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
111        line3.setAttribute('x1', '3');
112        line3.setAttribute('y1', '10');
113        line3.setAttribute('x2', '21');
114        line3.setAttribute('y2', '10');
115        svg.appendChild(line3);
116
117        btn.appendChild(svg);
118        var label = document.createElement('span');
119        label.textContent = 'Schedule Session';
120        btn.appendChild(label);
121
122        btn.onmouseover = function() { btn.style.background = 'rgba(37,99,235,0.16)'; };
123        btn.onmouseout = function() { btn.style.background = 'rgba(37,99,235,0.08)'; };
124        btn.onclick = function() { showCreateModal(); };
125
126        joinArea.appendChild(btn);
127    }
128
129    // ==========================================
130    // CREATE MODAL
131    // ==========================================
132
133    function showCreateModal() {
134        var existing = document.getElementById('scheduleModal');
135        if (existing) existing.remove();
136
137        var overlay = document.createElement('div');
138        overlay.id = 'scheduleModal';
139        overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:10001;';
140        overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
141
142        var card = document.createElement('div');
143        // Theme tokens, not a hard-coded dark slab: this modal was a dark island
144        // over the default light room (2026-08-09 audit, CR-03).
145        card.style.cssText = 'background:var(--surface-primary, #141824);border:1px solid var(--border-primary, rgba(255,255,255,0.1));border-radius:16px;padding:32px;max-width:480px;width:90%;max-height:90vh;overflow-y:auto;';
146
147        // Title
148        var title = document.createElement('h3');
149        title.style.cssText = 'color:var(--text-primary, #F7F9FC);margin:0 0 20px;font-size:20px;';
150        title.textContent = 'Schedule Interpreting Session';
151        card.appendChild(title);
152
153        // Form fields
154        var fields = [
155            { id: 'schedTitle', label: 'Session Title', type: 'text', placeholder: 'e.g. NHS Patient Consultation', required: true },
156            { id: 'schedDate', label: 'Date & Time', type: 'datetime-local', required: true },
157            { id: 'schedDuration', label: 'Estimated Duration (min)', type: 'number', value: '30' },
158            { id: 'schedInvitees', label: 'Invitee Emails', type: 'text', placeholder: '[email protected], [email protected]' },
159            { id: 'schedNotes', label: 'Notes (optional)', type: 'textarea', placeholder: 'Any context for participants...' }
160        ];
161
162        fields.forEach(function(f) {
163            var group = document.createElement('div');
164            group.style.cssText = 'margin-bottom:16px;';
165
166            var lbl = document.createElement('label');
167            lbl.style.cssText = 'display:block;color:var(--text-secondary, #B4B4C7);font-size:13px;margin-bottom:6px;';
168            lbl.textContent = f.label;
169            lbl.setAttribute('for', f.id);
170            group.appendChild(lbl);
171
172            var input;
173            if (f.type === 'textarea') {
174                input = document.createElement('textarea');
175                input.rows = 3;
176            } else {
177                input = document.createElement('input');
178                input.type = f.type;
179            }
180            input.id = f.id;
181            input.style.cssText = 'width:100%;background:var(--surface-secondary, rgba(255,255,255,0.04));border:1px solid var(--border-primary, rgba(255,255,255,0.1));color:var(--text-primary, #F7F9FC);padding:10px 12px;border-radius:8px;font-size:14px;box-sizing:border-box;';
182            if (f.placeholder) input.placeholder = f.placeholder;
183            if (f.value) input.value = f.value;
184            if (f.required) input.required = true;
185            group.appendChild(input);
186
187            card.appendChild(group);
188        });
189
190        // Set default datetime to 1 hour from now
191        var now = new Date();
192        now.setHours(now.getHours() + 1, 0, 0, 0);
193        var dateInput = card.querySelector('#schedDate');
194        if (dateInput) {
195            dateInput.value = now.toISOString().slice(0, 16);
196        }
197
198        // Actions
199        var actions = document.createElement('div');
200        actions.style.cssText = 'display:flex;gap:12px;margin-top:24px;';
201
202        var cancelBtn = document.createElement('button');
203        cancelBtn.style.cssText = 'flex:1;background:var(--btn-neutral-bg, rgba(255,255,255,0.06));color:var(--text-secondary, #B4B4C7);border:1px solid var(--btn-neutral-border, rgba(255,255,255,0.1));padding:12px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;';
204        cancelBtn.textContent = 'Cancel';
205        cancelBtn.onclick = function() { overlay.remove(); };
206        actions.appendChild(cancelBtn);
207
208        var createBtn = document.createElement('button');
209        createBtn.style.cssText = 'flex:1;background:#2563EB;color:#fff;border:none;padding:12px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;';
210        createBtn.textContent = 'Schedule';
211        createBtn.onclick = function() { handleCreateSession(overlay); };
212        actions.appendChild(createBtn);
213
214        card.appendChild(actions);
215        overlay.appendChild(card);
216        document.body.appendChild(overlay);
217    }
218
219    function handleCreateSession(overlay) {
220        var token = getAuthToken();
221        if (!token) { showNotification('Please sign in first', 'warning'); return; }
222
223        var title = document.getElementById('schedTitle')?.value?.trim();
224        var scheduledAt = document.getElementById('schedDate')?.value;
225        var duration = parseInt(document.getElementById('schedDuration')?.value) || 30;
226        var inviteeStr = document.getElementById('schedInvitees')?.value?.trim();
227        var notes = document.getElementById('schedNotes')?.value?.trim();
228
229        if (!title) { showNotification('Title is required', 'warning'); return; }
230        if (!scheduledAt) { showNotification('Date & time is required', 'warning'); return; }
231
232        var invitees = inviteeStr ? inviteeStr.split(',').map(function(e) { return e.trim(); }).filter(function(e) { return e.includes('@'); }) : [];
233
234        var body = {
235            title: title,
236            scheduledAt: new Date(scheduledAt).toISOString(),
237            estimatedDurationMin: duration,
238            invitees: invitees,
239            notes: notes || undefined
240        };
241
242        fetch('/api/schedule/create', {
243            method: 'POST',
244            headers: {
245                'Authorization': 'Bearer ' + token,
246                'Content-Type': 'application/json'
247            },
248            body: JSON.stringify(body)
249        })
250        .then(function(r) { return r.json(); })
251        .then(function(data) {
252            if (data.error) {
253                showNotification(data.error, 'warning');
254                return;
255            }
256            overlay.remove();
257            showNotification('Session scheduled! Room: ' + data.roomCode + (data.invitesSent > 0 ? ' (' + data.invitesSent + ' invite(s) sent)' : ''), 'success');
258        })
259        .catch(function() {
260            showNotification('Failed to create session', 'warning');
261        });
262    }
263
264    // ==========================================
265    // MY SESSIONS PANEL
266    // ==========================================
267
268    function showSessionsPanel() {
269        var existing = document.getElementById('sessionsPanel');
270        if (existing) { existing.remove(); return; }
271
272        var overlay = document.createElement('div');
273        overlay.id = 'sessionsPanel';
274        overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;justify-content:flex-end;z-index:10000;';
275        overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); };
276
277        var panel = document.createElement('div');
278        panel.style.cssText = 'width:420px;max-width:90vw;height:100%;background:var(--surface-primary, #0F1219);border-left:1px solid var(--border-primary, rgba(255,255,255,0.08));display:flex;flex-direction:column;overflow:hidden;';
279
280        // Header
281        var header = document.createElement('div');
282        header.style.cssText = 'padding:20px;border-bottom:1px solid var(--border-primary, rgba(255,255,255,0.08));display:flex;justify-content:space-between;align-items:center;';
283
284        var headerTitle = document.createElement('h3');
285        headerTitle.style.cssText = 'color:var(--text-primary, #F7F9FC);margin:0;font-size:18px;';
286        headerTitle.textContent = 'Scheduled Sessions';
287        header.appendChild(headerTitle);
288
289        var closeBtn = document.createElement('button');
290        closeBtn.style.cssText = 'background:none;border:none;color:var(--text-tertiary, #6B6B80);font-size:24px;cursor:pointer;padding:0 4px;';
291        closeBtn.textContent = '\u00D7';
292        closeBtn.onclick = function() { overlay.remove(); };
293        header.appendChild(closeBtn);
294
295        panel.appendChild(header);
296
297        // New session button
298        var newBtnWrap = document.createElement('div');
299        newBtnWrap.style.cssText = 'padding:12px 20px;';
300
301        var newBtn = document.createElement('button');
302        newBtn.style.cssText = 'width:100%;background:rgba(37,99,235,0.1);border:1px solid rgba(37,99,235,0.3);color:#60A5FA;padding:10px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;';
303        newBtn.textContent = '+ New Session';
304        newBtn.onclick = function() { overlay.remove(); showCreateModal(); };
305        newBtnWrap.appendChild(newBtn);
306        panel.appendChild(newBtnWrap);
307
308        var listEl = document.createElement('div');
309        listEl.id = 'sessionsList';
310        listEl.style.cssText = 'flex:1;overflow-y:auto;padding:0 16px 16px;';
311        panel.appendChild(listEl);
312
313        overlay.appendChild(panel);
314        document.body.appendChild(overlay);
315
316        loadSessions();
317    }
318
319    function loadSessions() {
320        var listEl = document.getElementById('sessionsList');
321        if (!listEl) return;
322
323        listEl.textContent = '';
324        var loadingMsg = document.createElement('div');
325        loadingMsg.style.cssText = 'text-align:center;padding:40px;color:var(--text-tertiary, #6B6B80);';
326        loadingMsg.textContent = 'Loading...';
327        listEl.appendChild(loadingMsg);
328
329        var token = getAuthToken();
330        if (!token) {
331            listEl.textContent = '';
332            var msg = document.createElement('div');
333            msg.style.cssText = 'text-align:center;padding:40px;color:var(--text-tertiary, #6B6B80);';
334            msg.textContent = 'Please sign in to view sessions.';
335            listEl.appendChild(msg);
336            return;
337        }
338
339        fetch('/api/schedule/my-sessions', {
340            headers: { 'Authorization': 'Bearer ' + token }
341        })
342        .then(function(r) { if (!r.ok) throw new Error(r.status); return r.json(); })
343        .then(function(data) {
344            listEl.textContent = '';
345
346            if (!data.sessions || data.sessions.length === 0) {
347                var emptyMsg = document.createElement('div');
348                emptyMsg.style.cssText = 'text-align:center;padding:40px;color:var(--text-tertiary, #6B6B80);';
349                emptyMsg.textContent = 'No scheduled sessions yet.';
350                listEl.appendChild(emptyMsg);
351                return;
352            }
353
354            data.sessions.forEach(function(session) {
355                var item = document.createElement('div');
356                item.style.cssText = 'background:var(--surface-secondary, rgba(255,255,255,0.03));border:1px solid var(--border-primary, rgba(255,255,255,0.06));border-radius:12px;padding:16px;margin-bottom:12px;';
357
358                var statusColors = {
359                    pending: '#60A5FA', reminded: '#60A5FA', active: '#10B981',
360                    completed: '#6B6B80', cancelled: '#ef4444', expired: '#6B6B80'
361                };
362                var statusColor = statusColors[session.status] || '#6B6B80';
363
364                // Title row
365                var titleRow = document.createElement('div');
366                titleRow.style.cssText = 'display:flex;justify-content:space-between;align-items:start;margin-bottom:8px;';
367
368                var titleDiv = document.createElement('div');
369                var titleH4 = document.createElement('h4');
370                titleH4.style.cssText = 'color:var(--text-primary, #F7F9FC);margin:0;font-size:14px;';
371                titleH4.textContent = session.title;
372                titleDiv.appendChild(titleH4);
373
374                var dateP = document.createElement('p');
375                dateP.style.cssText = 'color:var(--text-tertiary, #6B6B80);font-size:12px;margin:4px 0 0;';
376                dateP.textContent = formatDate(session.scheduled_at) + ' at ' + formatTime(session.scheduled_at);
377                titleDiv.appendChild(dateP);
378                titleRow.appendChild(titleDiv);
379
380                var badge = document.createElement('span');
381                badge.style.cssText = 'font-size:11px;padding:2px 8px;border-radius:4px;font-weight:600;text-transform:uppercase;color:' + statusColor + ';background:' + statusColor + '20;';
382                badge.textContent = session.status;
383                titleRow.appendChild(badge);
384
385                item.appendChild(titleRow);
386
387                // Meta
388                var meta = document.createElement('div');
389                meta.style.cssText = 'display:flex;gap:12px;color:var(--text-tertiary, #6B6B80);font-size:12px;margin-bottom:12px;';
390
391                var roomSpan = document.createElement('span');
392                roomSpan.textContent = 'Room: ' + session.room_code;
393                meta.appendChild(roomSpan);
394
395                if (session.status === 'pending' || session.status === 'reminded') {
396                    var countdownSpan = document.createElement('span');
397                    countdownSpan.style.color = '#60A5FA';
398                    countdownSpan.textContent = 'In ' + timeUntil(session.scheduled_at);
399                    meta.appendChild(countdownSpan);
400                }
401
402                var guestsSpan = document.createElement('span');
403                guestsSpan.textContent = (session.guest_tokens?.length || 0) + ' invitee(s)';
404                meta.appendChild(guestsSpan);
405
406                item.appendChild(meta);
407
408                // Actions for pending/reminded sessions
409                if (session.status === 'pending' || session.status === 'reminded') {
410                    var actions = document.createElement('div');
411                    actions.style.cssText = 'display:flex;gap:8px;';
412
413                    var startBtn = document.createElement('button');
414                    startBtn.style.cssText = 'flex:1;background:#10B981;color:#fff;border:none;padding:8px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;';
415                    startBtn.textContent = 'Start Now';
416                    startBtn.setAttribute('data-id', session.id);
417                    startBtn.setAttribute('data-room', session.room_code);
418                    startBtn.onclick = function() {
419                        var sid = this.getAttribute('data-id');
420                        var room = this.getAttribute('data-room');
421                        startSession(sid, room);
422                    };
423                    actions.appendChild(startBtn);
424
425                    var cancelSessionBtn = document.createElement('button');
426                    cancelSessionBtn.style.cssText = 'background:none;border:1px solid rgba(239,68,68,0.3);color:#ef4444;padding:8px 16px;border-radius:6px;font-size:12px;cursor:pointer;';
427                    cancelSessionBtn.textContent = 'Cancel';
428                    cancelSessionBtn.setAttribute('data-id', session.id);
429                    cancelSessionBtn.onclick = function() { cancelSession(this.getAttribute('data-id')); };
430                    actions.appendChild(cancelSessionBtn);
431
432                    item.appendChild(actions);
433                }
434
435                listEl.appendChild(item);
436            });
437        })
438        .catch(function() {
439            listEl.textContent = '';
440            var errMsg = document.createElement('div');
441            errMsg.style.cssText = 'text-align:center;padding:40px;color:#ef4444;';
442            errMsg.textContent = 'Failed to load sessions.';
443            listEl.appendChild(errMsg);
444        });
445    }
446
447    function startSession(sessionId, roomCode) {
448        var token = getAuthToken();
449        fetch('/api/schedule/' + encodeURIComponent(sessionId) + '/start', {
450            method: 'POST',
451            headers: { 'Authorization': 'Bearer ' + token }
452        })
453        .then(function(r) { return r.json(); })
454        .then(function(data) {
455            if (data.error) { showNotification(data.error, 'warning'); return; }
456            // Close panel and join room
457            var panel = document.getElementById('sessionsPanel');
458            if (panel) panel.remove();
459            showNotification('Session started! Joining room ' + roomCode, 'success');
460            // Auto-join. The old code looked for #joinRoomBtn and
461            // button[data-action="join"] — neither id exists anywhere in the app,
462            // so the toast said "Joining room X" and nothing ever happened
463            // (2026-07-29). The real control is #joinBtn, and precall-check.js
464            // intercepts it in the capture phase to run its mic/speaker wizard,
465            // which is the correct destination: the user confirms their devices
466            // and the wizard's own Join takes them in.
467            var roomInput = document.getElementById('roomCode');
468            var joinBtn = document.getElementById('joinBtn');
469            if (roomInput && joinBtn) {
470                roomInput.value = roomCode;
471                roomInput.dispatchEvent(new Event('input', { bubbles: true }));
472                joinBtn.click();
473            } else {
474                showNotification('Room ' + roomCode + ' is ready — enter it on the join screen.', 'info');
475            }
476        })
477        .catch(function() { showNotification('Failed to start session', 'warning'); });
478    }
479
480    function cancelSession(sessionId) {
481        if (!confirm('Cancel this scheduled session?')) return;
482        var token = getAuthToken();
483        fetch('/api/schedule/' + encodeURIComponent(sessionId), {
484            method: 'DELETE',
485            headers: { 'Authorization': 'Bearer ' + token }
486        })
487        .then(function() { loadSessions(); showNotification('Session cancelled', 'success'); })
488        .catch(function() { showNotification('Failed to cancel session', 'warning'); });
489    }
490
491    window.showSessionsPanel = showSessionsPanel;
492    window.showScheduleCreate = showCreateModal;
493
494    // ==========================================
495    // UPCOMING SESSION BANNER
496    // ==========================================
497
498    function checkUpcomingSessions() {
499        var token = getAuthToken();
500        if (!token) return;
501
502        fetch('/api/schedule/upcoming', {
503            headers: { 'Authorization': 'Bearer ' + token }
504        })
505        .then(function(r) { return r.json(); })
506        .then(function(data) {
507            if (!data.sessions || data.sessions.length === 0) return;
508
509            // Show banner for session within 15 minutes
510            var soonSession = data.sessions.find(function(s) {
511                var diff = new Date(s.scheduled_at).getTime() - Date.now();
512                return diff > 0 && diff <= 15 * 60 * 1000;
513            });
514
515            if (soonSession) {
516                showUpcomingBanner(soonSession);
517            }
518        })
519        .catch(function() { /* ignore */ });
520    }
521
522    function showUpcomingBanner(session) {
523        if (document.getElementById('upcomingBanner')) return;
524
525        var banner = document.createElement('div');
526        banner.id = 'upcomingBanner';
527        banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:linear-gradient(90deg,#1e3a5f,#2563EB);color:#fff;padding:10px 20px;font-size:14px;display:flex;align-items:center;justify-content:center;gap:12px;z-index:9998;box-shadow:0 2px 10px rgba(0,0,0,0.3);';
528
529        var text = document.createElement('span');
530        text.textContent = 'Upcoming: "' + session.title + '" starts in ' + timeUntil(session.scheduled_at);
531        banner.appendChild(text);
532
533        var joinBtn = document.createElement('button');
534        joinBtn.style.cssText = 'background:#10B981;color:#fff;border:none;padding:6px 16px;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;';
535        joinBtn.textContent = 'Join Now';
536        joinBtn.onclick = function() {
537            startSession(session.id, session.room_code);
538            banner.remove();
539        };
540        banner.appendChild(joinBtn);
541
542        var dismissBtn = document.createElement('button');
543        dismissBtn.style.cssText = 'background:none;border:none;color:rgba(255,255,255,0.6);font-size:18px;cursor:pointer;margin-left:4px;';
544        dismissBtn.textContent = '\u00D7';
545        dismissBtn.onclick = function() { banner.remove(); };
546        banner.appendChild(dismissBtn);
547
548        document.body.appendChild(banner);
549    }
550
551    // ==========================================
552    // INITIALIZATION
553    // ==========================================
554
555    // Inject schedule button when pre-room screen is visible
556    var scheduleBtnInjected = false;
557    var scheduleObserver = new MutationObserver(function() {
558        if (scheduleBtnInjected) return;
559        injectScheduleButton();
560        if (document.getElementById('scheduleBtn')) {
561            scheduleBtnInjected = true;
562            scheduleObserver.disconnect();
563        }
564    });
565    scheduleObserver.observe(document.body, { childList: true, subtree: true });
566
567    // Check for upcoming sessions every 2 minutes
568    setTimeout(checkUpcomingSessions, 3000);
569    var upcomingInterval = setInterval(checkUpcomingSessions, 120000);
570    window.addEventListener('beforeunload', function() { clearInterval(upcomingInterval); });
571
572    console.log('Schedule UI loaded');
573})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.