/* ===================================================================== * realtime.js — 学園祭2026 リアルタイム更新クライアント(window.gsRT) * * VPS の Socket.IO(名前空間 /gakuensai)で「変わった」という合図を中継する。 * データは載せない。受信した画面は PHP から最新を取り直す * (DB が真実のソース。synvas / conference と同じクライアント通知型)。 * * ★ ルーム名は PHP(bootstrap.php の gsRoom)が作って API で配る。ここで組み立てない。 * ★ Socket.IO 未読込・VPS 未配備でも通知なしでも定期再取得を続ける。 * ただしレジの「売上画面とのセット」は在室が確認できないと決済できないので、 * 未配備のときは管理画面で「セットを必須にする」をオフにして運用する。 * * 読み込み: Socket.IO(https://cdn.socket.io/4.7.4/socket.io.min.js)の後に置く * * 使い方: * gsRT.join(room, { sale_changed: fn, account_changed: fn }) ルームに入り、合図を受ける * gsRT.leave(room) * gsRT.notify(room|rooms, 'sale_changed') 同室の他の画面へ合図 * gsRT.pair(room, 'register'|'sales', onStatus, screenNo) レジと売上画面のセット * gsRT.unpair(room) * gsRT.onState(fn) fn(接続中か) * gsRT.available() Socket.IO が使えるか * ===================================================================== */ (function () { 'use strict'; const VPS_URL = 'https://ws.' + location.hostname + '/gakuensai'; // finance_changed は会計報告(VPS/gakuensai.js の EVENTS と揃える) const EVENTS = ['sale_changed', 'account_changed', 'stock_changed', 'chuber_changed', 'finance_changed']; let socket = null; const rooms = {}; // room => { event: [fn, ...] } const pairs = {}; // room => { role, onStatus } const stateHandlers = []; const debounce = {}; // room => timer(連続通知でも400msごとに更新) const pending = {}; // room => Set const outbox = new Map(); // 接続前の通知。決済データは保持しない let pollTimer = null; function refreshAll() { Object.keys(rooms).forEach((room) => { Object.keys(rooms[room]).forEach((event) => dispatch(room, event)); }); } function startPolling() { if (pollTimer) return; pollTimer = setInterval(() => { // 売上端末は背景でも失効を確認する(在室したまま残さない)。 const hasSales = Object.keys(pairs).some((room) => pairs[room].role === 'sales'); if (document.visibilityState !== 'hidden' || hasSales) refreshAll(); }, 30000); } /** * 売上画面(role が sales のセット)が見えなくなったら、すぐセットから抜ける。 * ★ タブを閉じる・別のアプリへ切り替える・画面を消すと、ソケットはすぐには切れない。 * VPS が応答の途絶に気づくまで(Socket.IO の ping で最大45秒ほど)売上画面が「つながっている」ままになり、 * レジが止まるのが30秒ほど遅れていた(2026-09-26 実機)。見えなくなった時点で pair_leave を送り、 * 見えるようになったら入り直す(レジは LOST_GRACE の間に戻れば止まらない)。 */ function isHidden() { return document.visibilityState === 'hidden'; } function leaveSalesPairs() { if (!socket || !socket.connected) return; Object.keys(pairs).forEach((room) => { if (pairs[room].role === 'sales') socket.emit('pair_leave', { room: room }); }); } function joinPair(room) { const p = pairs[room]; if (!p || !socket || !socket.connected) return; if (p.role === 'sales' && isHidden()) return; // 見えていない売上画面は数えない socket.emit('pair_join', { room: room, role: p.role, screen: p.screen }); } document.addEventListener('visibilitychange', () => { if (isHidden()) { leaveSalesPairs(); return; } Object.keys(pairs).forEach((room) => { if (pairs[room].role === 'sales') joinPair(room); }); refreshAll(); }); // タブを閉じる・別のページへ移る(visibilitychange が来ないブラウザがある)。 // 戻る操作でページが復元されたら入り直す if (typeof window.addEventListener === 'function') { window.addEventListener('pagehide', leaveSalesPairs); window.addEventListener('pageshow', () => { if (!isHidden()) Object.keys(pairs).forEach(joinPair); }); } function ensureSocket() { if (socket) return socket; if (typeof io === 'undefined') return null; // Socket.IO 未読込 → リアルタイム無効 try { // レジは一日中つなぎっぱなしなので、再接続は諦めない(間隔は最大5秒) socket = io(VPS_URL, { reconnectionDelayMax: 5000, timeout: 8000 }); } catch (e) { socket = null; return null; } socket.on('connect', () => { // ★ connect は再接続のたびに発火する。入っていたルーム・セットに入り直す const names = Object.keys(rooms); if (names.length) socket.emit('join', { rooms: names }); Object.keys(pairs).forEach(joinPair); fireState(true); // 切断中の合図はサーバーに保存されないので、PHPから取り直す。 refreshAll(); outbox.forEach((message) => socket.emit('notify', message)); outbox.clear(); }); socket.on('disconnect', () => fireState(false)); // 名前空間未配備・接続不可は黙って無視(通常動作は継続。状態だけ知らせる) socket.on('connect_error', () => fireState(false)); EVENTS.forEach((event) => { socket.on(event, (d) => dispatch(d && d.room, event)); }); socket.on('pair_status', (d) => { const p = d && pairs[d.room]; if (p && typeof p.onStatus === 'function') { p.onStatus({ sales: Number(d.sales || 0), registers: Number(d.registers || 0), // つながっている売上画面の番号(レジの「不要な売上管理画面を閉じる」で使う) screens: Array.isArray(d.screens) ? d.screens.map(Number) : [] }); } }); return socket; } /** 同じ更新処理をまとめる。通知が続いても実行を先延ばしにしない。 */ function dispatch(room, event) { const handlers = room && rooms[room] && rooms[room][event]; if (!handlers || !handlers.length) return; if (!pending[room]) pending[room] = new Set(); handlers.forEach((fn) => pending[room].add(fn)); if (debounce[room]) return; debounce[room] = setTimeout(() => { delete debounce[room]; const batch = pending[room]; delete pending[room]; if (!rooms[room] || !batch) return; batch.forEach((fn) => { try { Promise.resolve(fn(room)).catch((e) => console.warn('gsRT handler failed:', e)); } catch (e) { console.warn('gsRT handler failed:', e); } }); }, 400); } function fireState(connected) { stateHandlers.forEach((fn) => { try { fn(connected); } catch (e) { console.warn(e); } }); } window.gsRT = { available() { return typeof io !== 'undefined'; }, connected() { return !!(socket && socket.connected); }, join(room, handlers) { if (!room) return; rooms[room] = rooms[room] || {}; Object.keys(handlers || {}).forEach((event) => { if (EVENTS.indexOf(event) < 0 || typeof handlers[event] !== 'function') return; const list = rooms[room][event] = rooms[room][event] || []; if (list.indexOf(handlers[event]) < 0) list.push(handlers[event]); }); startPolling(); // CDN/VPS障害時も補完更新は続ける const s = ensureSocket(); if (s && s.connected) s.emit('join', { rooms: [room] }); }, leave(room) { if (!room || !rooms[room]) return; delete rooms[room]; clearTimeout(debounce[room]); delete debounce[room]; delete pending[room]; if (!Object.keys(rooms).length) { clearInterval(pollTimer); pollTimer = null; } if (socket && socket.connected) socket.emit('leave', { rooms: [room] }); }, notify(target, event) { const list = (Array.isArray(target) ? target : [target]).filter(Boolean); if (!list.length || EVENTS.indexOf(event) < 0) return; const s = ensureSocket(); list.forEach((room) => { const message = { rooms: [room], event: event }; if (s && s.connected) s.emit('notify', message); else { outbox.set(room + '|' + event, message); if (outbox.size > 100) outbox.delete(outbox.keys().next().value); } }); }, /** * @param {number} [screen] 売上画面の番号(role が sales のとき。レジ側に「接続中」として出る) */ pair(room, role, onStatus, screen) { if (!room) return; pairs[room] = { role: role === 'sales' ? 'sales' : 'register', onStatus: onStatus, screen: screen || null }; ensureSocket(); joinPair(room); }, unpair(room) { if (!room || !pairs[room]) return; delete pairs[room]; if (socket && socket.connected) socket.emit('pair_leave', { room: room }); }, onState(fn) { if (typeof fn !== 'function') return; stateHandlers.push(fn); const s = ensureSocket(); // 先にjoin等で接続が済んでいても、購読者は現在の状態を受け取る。 // ★ ただし「接続中(まだつながっていない)」は知らせない。知らせると、画面を開くたびに // 売上画面へ「通信が切れています」が一瞬出る。つながれば connect、失敗すれば // connect_error で届く。Socket.IO 自体が読めないときは、どちらも来ないので今知らせる if (s && !s.connected) return; try { fn(!!s); } catch (e) { console.warn(e); } } }; })();