Congress Mission source
Every file that ships, readable right here. MIT licence. Prefer it offline? Download the .zip.
README.md89 lines
# Congress MissionYour team's mission control at a medical congress. Plan who covers which sessions, share insights and flash alerts the minute they happen, message the team, and leave each day with the debrief already written. Phones link **directly to each other** over the same WiFi or a phone hotspot: no server, no accounts, nothing stored outside the team's devices.Live at [mafflab.xyz/apps/congress-mission/](https://mafflab.xyz/apps/congress-mission/). Installable, works offline, no cookies, no tracking, no generative AI.## What it does- **Control:** today's mission at a glance: your next session with a countdown, sessions running now and next with who is on them, coverage of priority sessions with one-tap "I'll cover", the latest flashes, the crew's status and location, and a live map of which phones are linked.- **Plan:** import the congress agenda (.ics from the congress app, or CSV), set priorities (P1 must cover, P2 should, P3 if possible), assign yourself, spot clashes and sessions nobody is covering, mark sessions covered.- **Intel:** capture an insight in seconds (data, expert view, competitor, unmet need, question for Medical Information), tied to the session, with a "so what". Mark it **Flash** and every linked phone sounds, vibrates and shows it. Teammates mark insights as seen.- **Comms:** team chat plus one-tap pings ("Need backup here", "Save me a seat"). A "Need backup" status alerts everyone.- **Debrief:** generated for any day or the whole congress: flashes, insights by theme, questions for Medical Information, sessions reported and missed. Copy as text, download Markdown or CSV, or print to PDF.- **Demo:** a fictional congress (AURORA 2026) with a simulated crew and fictional medicines, so anyone can try it alone.## How the phones link``` Laura's phone Javier's phone 1. shows an invite QR ───────► scans it (camera app or in the app) 2. shows a reply QR scans the reply ◄─────── 3. WebRTC data channel, encrypted end to end (DTLS), over the local network Priya pairs with Javier the same way. Laura and Priya then link by themselves: their connection offers travel through Javier's phone, so nobody scans twice.```- **Pairing codes** (`mesh.js`) carry a compact form of the WebRTC session description: ICE username and password, the DTLS certificate fingerprint and the local candidates, plus the mission name and who is inviting. About 200 characters, so the QR code stays small enough for a phone to read from another phone's screen (version 10, 57 modules). Anything the compact form cannot hold is sent compressed instead. Codes work once, for 10 minutes.- **Mesh:** once linked to one teammate, a phone links to the rest automatically, with offers and answers relayed through linked phones. Every change is gossiped to all linked phones; if a direct link cannot form, data still flows through the others.- **Sync** (`store.js`): every record is a set of fields, and every field carries the hybrid logical clock of its last write. Copies merge field by field, newest clock wins (a last-writer-wins map, a simple CRDT), so phones that meet in any order end up identical. When two phones link they compare per-record digests and send each other whatever the other is missing, so a phone that was away catches up on relinking.- **No server.** By default nothing outside the team's phones is contacted, not even mafflab.xyz after the app has loaded. A setting, **Link across networks**, adds a public STUN server (Google) so phones on mobile data or different networks can try to link; messages still go phone to phone, but that server sees each phone's IP address.- **Relinking.** Phones send each other a heartbeat every 4 seconds and watch the connection state, so a dead link is dropped within about 15 seconds, and at once when the phone comes back from sleep or changes network. A link made by scanning codes always replaces any older link to the same teammate on both phones, so relinking works even while an old, dead link is still held. Messages that arrive before a link's handshake are kept, and each side confirms the handshake with a digest of its data so anything missing is sent again.- **Removing a teammate** marks them as removed: phones refuse their links and ignore changes they make afterwards.### What to expect at a real congress- Phones must be able to see each other on the network. Many congress and hotel WiFi networks isolate devices from each other; the reliable fallback is a **hotspot on one phone** that the others join.- A link lasts while the app is open and both phones are on the network. A short drop (a few seconds) heals by itself. After a longer one, both phones notice within about 15 seconds and show **Relink** (on Control, in Crew next to the teammate, and on the link indicator in the header): one of you shows a code, the other scans it with **Scan their code**. Either phone can show the code, and linking with any one teammate is enough, because the rest link through them. Everything written while apart is saved on each phone and syncs on relinking. "Keep the screen on" in Crew helps keep links up, at a battery cost.- Notifications while the app is in the background depend on the phone: Android keeps the app running for a while, iPhone suspends it quickly.- Each phone keeps the mission in the browser's storage. Export a backup now and then: it also moves a mission to a new phone, or syncs two phones that cannot link.- On iPhone, an invite scanned with the Camera app opens in Safari, which keeps its data separately from an installed home-screen app. If you installed the app, scan from inside it (Link, Scan their code).## Files```congress-mission/├── index.html, app.js, style.css, mafflab-theme.css├── store.js records, clocks, merge, digests, calendar import (also runs in Node)├── mesh.js pairing codes, WebRTC links, mesh relay, gossip sync (codec also runs in Node)├── demo.js the fictional AURORA 2026 crew├── scan-worker.js reads QR codes from camera frames with jsQR, off the main thread├── vendor/ qrcode-generator (MIT) and jsQR (Apache 2.0), unmodified├── manifest.webmanifest, sw.js, .htaccess, icons/, fonts/└── tests/ ├── store.test.js convergence of 4 devices under random concurrent edits, clocks, removal, ICS and CSV ├── signal.test.js pairing codes for Chrome, Firefox and Safari style SDP, IPv6, fallbacks ├── e2e.js three phones pair by camera, form a mesh, sync, alert and catch up ├── relink.e2e.js WiFi drops (all packets cut with iptables), self-healing, relinking, dead old links ├── phones.js shared helpers for the two browser tests └── fixtures/ SDP samples```## Tests```node tests/store.test.jsnode tests/signal.test.jsnode tests/e2e.js # needs Playwright with Chromium, and ffmpegsudo node tests/relink.e2e.js # also needs root, to cut the network with iptables```The relink test cuts every packet between two phones while both apps keep running, as when a phone leaves the WiFi, then restores it. It checks that 5- and 9-second drops heal by themselves; that after a long drop both phones notice, offer Relink and relink by scanning, three times in a row with either phone showing the code; and that relinking at once over a dead old link works, with the new link the one that stays.The end-to-end test runs three separate Chromium browsers, each with its own storage and its own fake camera. To scan, it screenshots the QR code on one "phone" and plays it to the other phone's camera as a video (ffmpeg), so the real scanner, the real codes and real WebRTC links are exercised. It covers pairing by camera, the automatic mesh link, flash alerts, a phone going away while the others keep talking, relinking and catching up, and identical data on all three phones at the end.Not tested here: real phones on real WiFi, Safari and Firefox (only their SDP formats are tested), and background behaviour on iOS and Android.## Limits- Phone-to-phone only: a teammate who never links with anyone never syncs (except by backup file).- Same network by default. Congress WiFi often blocks device-to-device traffic; use a hotspot.- Last writer wins per field: if two people edit the same session title at the same moment, one edit is kept.- Not a CRM or a regulatory record. Do not enter patient data, or names of healthcare professionals unless they spoke publicly on stage. Follow your company's insight-capture SOP.## LicenceMIT. © 2026 Aitor Martínez Rosell. Includes qrcode-generator (MIT, Kazuhiko Arase) and jsQR (Apache 2.0, Cosmo Wolfe); see `vendor/`. "QR Code" is a registered trademark of DENSO WAVE INCORPORATED.store.js313 lines
/* Congress Mission: the mission's data, kept on each device and merged when devices meet. Every record is a set of fields, and every field carries the hybrid logical clock (HLC) of the write that set it. Two copies of a record merge field by field: the newer clock wins. Merging is commutative, associative and idempotent, so devices that exchange changes in any order, any number of times, through any path, end up with the same data (a last-writer-wins map, a simple CRDT). Runs in the browser (window.CMStore) and in Node (module.exports) for the tests. */(function (root) { 'use strict'; /* ------------------------------------------------------------ ids and clocks */ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; function b64u(bytes) { let s = '', i = 0; for (; i + 2 < bytes.length; i += 3) { const n = bytes[i] << 16 | bytes[i + 1] << 8 | bytes[i + 2]; s += B64[n >> 18 & 63] + B64[n >> 12 & 63] + B64[n >> 6 & 63] + B64[n & 63]; } if (bytes.length - i === 1) { const n = bytes[i] << 16; s += B64[n >> 18 & 63] + B64[n >> 12 & 63]; } else if (bytes.length - i === 2) { const n = bytes[i] << 16 | bytes[i + 1] << 8; s += B64[n >> 18 & 63] + B64[n >> 12 & 63] + B64[n >> 6 & 63]; } return s; } function unb64u(s) { const out = []; let buf = 0, bits = 0; for (const ch of s) { const v = B64.indexOf(ch); if (v < 0) throw new Error('bad code'); buf = buf << 6 | v; bits += 6; if (bits >= 8) { bits -= 8; out.push(buf >> bits & 255); } } return new Uint8Array(out); } function rand(n) { const a = new Uint8Array(n); (root.crypto || require('crypto').webcrypto).getRandomValues(a); return a; } const rid = (n = 9) => b64u(rand(n)); // 9 bytes: 12 characters /* HLC as a fixed-width string, so clocks compare as plain strings: 9 base-36 digits of milliseconds, 3 of counter, then the writer's member id. */ const encH = (ms, c, node) => ms.toString(36).padStart(9, '0') + c.toString(36).padStart(3, '0') + node; const msOf = h => parseInt(h.slice(0, 9), 36); const cOf = h => parseInt(h.slice(9, 12), 36); const nodeOf = h => h.slice(12); const DAY = 864e5; class Clock { constructor(node, now) { this.node = node; this.ms = 0; this.c = 0; this.now = now || Date.now; } tick() { const t = this.now(); if (t > this.ms) { this.ms = t; this.c = 0; } else this.c++; return encH(this.ms, this.c, this.node); } recv(h) { const ms = msOf(h), c = cOf(h), t = this.now(); if (!(ms > 0) || ms > t + DAY) return; // ignore a peer whose clock is badly wrong const l = Math.max(this.ms, ms, t); if (l === this.ms && l === ms) this.c = Math.max(this.c, c) + 1; else if (l === this.ms) this.c++; else if (l === ms) this.c = c + 1; else this.c = 0; this.ms = l; } } /* ------------------------------------------------------------ schema */ const KINDS = { mission: /^(name|congress|city|start|end|created)$/, member: /^(name|role|color|st|stx|room|sat|rm|joined)$/, session: /^(title|day|start|end|room|prio|tag|note|cov|del|a\.[A-Za-z0-9_-]{6,16})$/, insight: /^(ses|cat|head|body|sowhat|flash|at|by|del|s\.[A-Za-z0-9_-]{6,16})$/, msg: /^(text|pri|at|by|del)$/, }; const MAXLEN = 4000; function okValue(v) { if (v === null || typeof v === 'boolean') return true; if (typeof v === 'number') return Number.isFinite(v); if (typeof v === 'string') return v.length <= MAXLEN; return false; } const okId = id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,24}$/.test(id); const okH = h => typeof h === 'string' && /^[0-9a-z]{12}[A-Za-z0-9_-]{6,16}$/.test(h); /* FNV-1a over the record's field clocks: two devices hold the same record exactly when the hashes match */ function hashRec(r) { let h = 0x811c9dc5; const keys = Object.keys(r.f).sort(); for (const k of keys) { const s = k + '=' + r.f[k][1] + '|'; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } } return h.toString(36); } /* ------------------------------------------------------------ store */ class Store { /* opts: { mission, me, storage (localStorage-like, optional), now } */ constructor(opts) { this.mission = opts.mission; this.me = opts.me; this.storage = opts.storage || null; this.clock = new Clock(opts.me, opts.now); this.recs = new Map(); this.subs = new Set(); this.saveT = 0; if (this.storage) this.load(); } key() { return 'cm.m.' + this.mission; } load() { try { const raw = this.storage.getItem(this.key()); if (!raw) return; for (const r of JSON.parse(raw)) { this.recs.set(r.id, r); for (const k in r.f) this.clock.recv(r.f[k][1]); } } catch (e) { /* unreadable copy: start empty rather than fail */ } } save(now) { if (!this.storage) return; const write = () => { this.saveT = 0; try { this.storage.setItem(this.key(), JSON.stringify([...this.recs.values()])); } catch (e) { this.emit({ type: 'save-error', error: e }); } }; if (now) { clearTimeout(this.saveT); write(); return; } if (!this.saveT) this.saveT = setTimeout(write, 300); } on(fn) { this.subs.add(fn); return () => this.subs.delete(fn); } emit(ev) { for (const fn of this.subs) { try { fn(ev); } catch (e) { console.error(e); } } } get(id) { return this.recs.get(id) || null; } val(id, field, dflt) { const r = this.recs.get(id); return r && r.f[field] ? r.f[field][0] : dflt; } obj(r) { const o = { id: r.id, k: r.k }; for (const k in r.f) o[k] = r.f[k][0]; return o; } all(kind) { const out = []; for (const r of this.recs.values()) if (r.k === kind && !(r.f.del && r.f.del[0])) out.push(this.obj(r)); return out; } removed(node) { const r = this.recs.get(node); return !!(r && r.k === 'member' && r.f.rm && r.f.rm[0]); } /* local write: fields are stamped with fresh clocks; returns the delta to broadcast */ set(id, kind, fields) { if (!okId(id) || !KINDS[kind]) throw new Error('bad record'); const cur = this.recs.get(id); if (cur && cur.k !== kind) throw new Error('kind mismatch'); const r = cur || { id, k: kind, f: {} }; const delta = { id, k: kind, f: {} }; for (const k in fields) { const v = fields[k] === undefined ? null : fields[k]; if (!KINDS[kind].test(k) || !okValue(v)) throw new Error('bad field ' + k); if (r.f[k] && r.f[k][0] === v) continue; // unchanged: no new write const h = this.clock.tick(); r.f[k] = [v, h]; delta.f[k] = [v, h]; } if (!Object.keys(delta.f).length) return null; this.recs.set(id, r); this.save(); this.emit({ type: 'change', origin: 'local', recs: [delta] }); return delta; } /* remote records: merge field by field; returns only what was new, for forwarding */ merge(list, origin) { const applied = []; if (!Array.isArray(list)) return applied; for (const inc of list) { if (!inc || !okId(inc.id) || !KINDS[inc.k] || !inc.f || typeof inc.f !== 'object') continue; const cur = this.recs.get(inc.id); if (cur && cur.k !== inc.k) continue; const r = cur || { id: inc.id, k: inc.k, f: {} }; const delta = { id: inc.id, k: inc.k, f: {} }; for (const k in inc.f) { const fv = inc.f[k]; if (!KINDS[inc.k].test(k) || !Array.isArray(fv) || fv.length !== 2 || !okValue(fv[0]) || !okH(fv[1])) continue; const author = nodeOf(fv[1]); if (author !== this.me && this.removed(author)) { const rm = this.recs.get(author).f.rm[1]; if (fv[1] > rm) continue; // writes by a removed member after removal are ignored } if (r.f[k] && r.f[k][1] >= fv[1]) continue; r.f[k] = [fv[0], fv[1]]; delta.f[k] = [fv[0], fv[1]]; this.clock.recv(fv[1]); } if (Object.keys(delta.f).length) { this.recs.set(inc.id, r); applied.push(delta); } } if (applied.length) { this.save(); this.emit({ type: 'change', origin: origin || 'remote', recs: applied }); } return applied; } digest() { const d = {}; for (const r of this.recs.values()) d[r.id] = hashRec(r); return d; } /* records the other device is missing or holds in a different state (it sends us its own the same way) */ diff(remote) { const out = []; if (!remote || typeof remote !== 'object') return out; for (const r of this.recs.values()) if (remote[r.id] !== hashRec(r)) out.push(r); return out; } snapshot() { return { v: 1, mission: this.mission, recs: [...this.recs.values()] }; } wipe() { this.recs.clear(); if (this.storage) { clearTimeout(this.saveT); this.saveT = 0; try { this.storage.removeItem(this.key()); } catch (e) {} } } } /* ------------------------------------------------------------ calendar imports */ const pad = n => String(n).padStart(2, '0'); const ymd = d => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); const hm = d => pad(d.getHours()) + ':' + pad(d.getMinutes()); /* offset in minutes of an IANA time zone at a UTC instant (Intl, no data files) */ function tzOffset(tz, utcMs) { const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); const p = {}; for (const x of f.formatToParts(new Date(utcMs))) p[x.type] = x.value; const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute, +p.second); return Math.round((asUtc - utcMs) / 6e4); } /* an ICS date-time to a JS Date in the device's time zone */ function icsDate(value, params) { const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/.exec(value.trim()); if (!m) return null; const [, Y, Mo, D, h, mi, s, z] = m; if (h === undefined) return { date: new Date(+Y, +Mo - 1, +D), allDay: true }; const wall = Date.UTC(+Y, +Mo - 1, +D, +h, +mi, +(s || 0)); if (z) return { date: new Date(wall) }; const tz = params.TZID; if (tz) { try { let off = tzOffset(tz, wall); off = tzOffset(tz, wall - off * 6e4); // second pass settles DST edges return { date: new Date(wall - off * 6e4) }; } catch (e) { /* unknown zone: treat as local wall time */ } } return { date: new Date(+Y, +Mo - 1, +D, +h, +mi, +(s || 0)) }; } const icsText = s => s.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1').trim(); function parseICS(text) { const lines = String(text).replace(/\r\n?/g, '\n').replace(/\n[ \t]/g, '').split('\n'); const out = []; let ev = null; for (const line of lines) { if (line === 'BEGIN:VEVENT') { ev = {}; continue; } if (line === 'END:VEVENT') { if (ev && ev.SUMMARY && ev.DTSTART) { const st = icsDate(ev.DTSTART.v, ev.DTSTART.p); if (st && !st.allDay) { const en = ev.DTEND ? icsDate(ev.DTEND.v, ev.DTEND.p) : null; const end = en && !en.allDay ? en.date : new Date(st.date.getTime() + 36e5); out.push({ title: icsText(ev.SUMMARY.v).slice(0, 300), day: ymd(st.date), start: hm(st.date), end: hm(end), room: ev.LOCATION ? icsText(ev.LOCATION.v).slice(0, 120) : '', note: ev.DESCRIPTION ? icsText(ev.DESCRIPTION.v).slice(0, 1000) : '' }); } } ev = null; continue; } if (!ev) continue; const c = line.indexOf(':'); if (c < 0) continue; const head = line.slice(0, c), v = line.slice(c + 1); const parts = head.split(';'), name = parts[0].toUpperCase(), p = {}; for (const x of parts.slice(1)) { const e = x.indexOf('='); if (e > 0) p[x.slice(0, e).toUpperCase()] = x.slice(e + 1).replace(/^"|"$/g, ''); } if (!ev[name]) ev[name] = { v, p }; } return out; } function parseCSV(text) { const rows = []; let row = [], cell = '', q = false; const s = String(text).replace(/^/, ''); const sep = (s.split('\n')[0].match(/;/g) || []).length > (s.split('\n')[0].match(/,/g) || []).length ? ';' : ','; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (q) { if (ch === '"') { if (s[i + 1] === '"') { cell += '"'; i++; } else q = false; } else cell += ch; } else if (ch === '"') q = true; else if (ch === sep) { row.push(cell); cell = ''; } else if (ch === '\n' || ch === '\r') { if (ch === '\r' && s[i + 1] === '\n') i++; row.push(cell); rows.push(row); row = []; cell = ''; } else cell += ch; } if (cell !== '' || row.length) { row.push(cell); rows.push(row); } const data = rows.filter(r => r.some(c => c.trim())); if (data.length < 2) return []; const head = data[0].map(h => h.trim().toLowerCase()); const col = re => head.findIndex(h => re.test(h)); const ci = { title: col(/^(title|session|name|summary)/), day: col(/^(day|date)/), start: col(/^(start|from|begin|time)/), end: col(/^(end|to|until|finish)/), room: col(/^(room|location|hall|place)/), prio: col(/^(prio|priority)/), tag: col(/^(tag|topic|type|category)/), note: col(/^(note|notes|description)/) }; if (ci.title < 0 || ci.day < 0 || ci.start < 0) return []; const normDay = v => { v = v.trim(); let m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(v); if (m) return m[1] + '-' + pad(+m[2]) + '-' + pad(+m[3]); m = /^(\d{1,2})[/.](\d{1,2})[/.](\d{4})$/.exec(v); // day first, as in most of Europe if (m) return m[3] + '-' + pad(+m[2]) + '-' + pad(+m[1]); return ''; }; const normTime = v => { const m = /^(\d{1,2})[:.h](\d{2})/.exec(String(v || '').trim()); return m && +m[1] < 24 && +m[2] < 60 ? pad(+m[1]) + ':' + m[2] : ''; }; const out = []; for (const r of data.slice(1)) { const g = k => ci[k] >= 0 ? (r[ci[k]] || '').trim() : ''; const title = g('title'), day = normDay(g('day')), start = normTime(g('start')); if (!title || !day || !start) continue; let end = normTime(g('end')); if (!end) { const [h, m] = start.split(':').map(Number); end = pad(Math.min(23, h + 1)) + ':' + pad(m); } const p = parseInt(g('prio'), 10); out.push({ title: title.slice(0, 300), day, start, end, room: g('room').slice(0, 120), prio: p >= 1 && p <= 3 ? p : 2, tag: g('tag').slice(0, 40), note: g('note').slice(0, 1000) }); } return out; } const API = { Store, Clock, rid, b64u, unb64u, rand, hashRec, encH, msOf, nodeOf, parseICS, parseCSV, ymd, hm, tzOffset, KINDS }; if (typeof module === 'object' && module.exports) module.exports = API; else root.CMStore = API;})(typeof self !== 'undefined' ? self : this);mesh.js508 lines
/* Congress Mission: devices talk to each other directly, with no server. Pairing. Two phones exchange WebRTC connection details face to face: the inviter shows a QR code, the new teammate scans it and shows a reply code, and the inviter scans that. Each code carries a compact form of the SDP (ICE credentials, the DTLS certificate fingerprint and local candidates), so it fits a QR code a phone can read from another phone's screen. Mesh. Once a device is linked to one teammate, it links to the rest by itself: the connection offers and answers travel through devices that are already linked. Every change is gossiped to all linked devices, and on every (re)connection the two devices compare digests and send each other whatever the other is missing (see store.js), so the team converges even with partial links. Privacy. WebRTC data channels are encrypted end to end (DTLS). Codes are exchanged in person. No server sees the data; by default no server is contacted at all, and devices find each other only on the same network (WiFi or a phone hotspot). An optional setting adds a public STUN server, which tells each device its public address so links can also form across networks. */(function (root) { 'use strict'; const S = root.CMStore || (typeof require === 'function' ? require('./store.js') : null); const te = new TextEncoder(), td = new TextDecoder(); /* ======================================================== compact signal codec */ const TYPES = ['host', 'srflx', 'prflx', 'relay']; const SETUPS = ['actpass', 'active', 'passive']; const UUID = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})\.local$/i; function parseSdp(sdp) { const L = String(sdp).split(/\r?\n/).filter(Boolean); const m = L.filter(l => l.startsWith('m=')); if (m.length !== 1 || !/^m=application \d+ UDP\/DTLS\/SCTP webrtc-datachannel/.test(m[0])) return null; const a = name => { const l = L.find(x => x.startsWith('a=' + name + ':')); return l ? l.slice(name.length + 3).trim() : null; }; const ufrag = a('ice-ufrag'), pwd = a('ice-pwd'), fpl = a('fingerprint'), setup = a('setup'), mid = a('mid'), port = a('sctp-port'); if (!ufrag || !pwd || !fpl || !mid || SETUPS.indexOf(setup) < 0 || (port && port !== '5000')) return null; const [alg, hex] = fpl.split(/\s+/); if (!alg || alg.toLowerCase() !== 'sha-256' || !hex) return null; const fp = hex.split(':').map(h => parseInt(h, 16)); if (fp.length !== 32 || fp.some(x => !(x >= 0 && x < 256))) return null; const cands = []; for (const l of L) { if (!l.startsWith('a=candidate:')) continue; const p = l.slice(12).split(/\s+/); if (p[1] !== '1' || p[2].toLowerCase() !== 'udp') continue; // RTP component only, UDP only const ti = TYPES.indexOf(p[7]); if (p[6] !== 'typ' || ti < 0) continue; const port = parseInt(p[5], 10); if (!(port > 0 && port < 65536)) continue; cands.push({ type: TYPES[ti], addr: p[4], port }); } if (!cands.length) return null; return { ufrag, pwd, fp, setup, mid, cands }; } function fpHex(fp) { return Array.from(fp, b => b.toString(16).toUpperCase().padStart(2, '0')).join(':'); } function buildSdp(o) { const id = String(Math.floor(Math.random() * 9e15) + 1e15); const out = ['v=0', 'o=- ' + id + ' 2 IN IP4 127.0.0.1', 's=-', 't=0 0', 'a=group:BUNDLE ' + o.mid, 'a=msid-semantic: WMS', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', 'c=IN IP4 0.0.0.0']; o.cands.forEach((c, i) => { const tp = { host: 126, prflx: 110, srflx: 100, relay: 0 }[c.type]; const prio = tp * 16777216 + (65535 - i) * 256 + 255; out.push('a=candidate:' + (i + 1) + ' 1 udp ' + prio + ' ' + c.addr + ' ' + c.port + ' typ ' + c.type + (c.type !== 'host' ? ' raddr 0.0.0.0 rport 0' : '')); }); out.push('a=ice-ufrag:' + o.ufrag, 'a=ice-pwd:' + o.pwd, 'a=ice-options:trickle', 'a=fingerprint:sha-256 ' + fpHex(o.fp), 'a=setup:' + o.setup, 'a=mid:' + o.mid, 'a=sctp-port:5000', 'a=max-message-size:262144'); return out.join('\r\n') + '\r\n'; } /* IPv4 and IPv6 to bytes and back; anything else (or odd forms) is sent as text */ function ip4(s) { const p = s.split('.'); if (p.length !== 4) return null; const b = p.map(Number); return b.every((x, i) => /^\d{1,3}$/.test(p[i]) && x < 256) ? b : null; } function ip6(s) { if (!/^[0-9a-f:]+$/i.test(s) || (s.match(/::/g) || []).length > 1) return null; const [h, t] = s.split('::'); const hp = h ? h.split(':') : [], tp = t !== undefined ? (t ? t.split(':') : []) : null; const groups = tp ? [...hp, ...Array(8 - hp.length - tp.length).fill('0'), ...tp] : hp; if (groups.length !== 8 || groups.some(g => !/^[0-9a-f]{1,4}$/i.test(g))) return null; const b = []; for (const g of groups) { const v = parseInt(g, 16); b.push(v >> 8, v & 255); } return b; } function ip6str(b) { const g = []; for (let i = 0; i < 16; i += 2) g.push(((b[i] << 8) | b[i + 1]).toString(16)); let best = -1, len = 0; for (let i = 0; i < 8; i++) { let j = i; while (j < 8 && g[j] === '0') j++; if (j - i > len && j - i > 1) { best = i; len = j - i; } } if (best < 0) return g.join(':'); return g.slice(0, best).join(':') + '::' + g.slice(best + len).join(':'); } function Writer() { this.b = []; } Writer.prototype.u8 = function (v) { this.b.push(v & 255); }; Writer.prototype.u16 = function (v) { this.b.push(v >> 8 & 255, v & 255); }; Writer.prototype.u32 = function (v) { this.b.push(v >>> 24 & 255, v >>> 16 & 255, v >>> 8 & 255, v & 255); }; Writer.prototype.bytes = function (a) { for (const x of a) this.b.push(x); }; Writer.prototype.str = function (s) { const e = te.encode(s || ''); if (e.length > 255) throw new Error('text too long for a code'); this.u8(e.length); this.bytes(e); }; function Reader(b) { this.b = b; this.i = 0; } Reader.prototype.need = function (n) { if (this.i + n > this.b.length) throw new Error('This code is incomplete. Scan it again.'); }; Reader.prototype.u8 = function () { this.need(1); return this.b[this.i++]; }; Reader.prototype.u16 = function () { this.need(2); const v = this.b[this.i] << 8 | this.b[this.i + 1]; this.i += 2; return v; }; Reader.prototype.u32 = function () { this.need(4); const v = (this.b[this.i] << 24 | this.b[this.i + 1] << 16 | this.b[this.i + 2] << 8 | this.b[this.i + 3]) >>> 0; this.i += 4; return v; }; Reader.prototype.bytes = function (n) { this.need(n); const v = this.b.slice(this.i, this.i + n); this.i += n; return v; }; Reader.prototype.str = function () { const n = this.u8(); return td.decode(this.bytes(n)); }; function writeMeta(w, m) { w.str(m.mission); w.str(m.mname); w.str(m.member); w.str(m.name); w.u32(m.nonce >>> 0); } function readMeta(r) { return { mission: r.str(), mname: r.str(), member: r.str(), name: r.str(), nonce: r.u32() }; } /* code layout: version byte, then 1: flags (bit 0 answer, bits 1-2 setup), meta, mid, ufrag, pwd, 32-byte fingerprint, candidates 2: deflate-raw JSON {type, sdp, meta} for SDP the compact form cannot hold 3: the same JSON uncompressed (browsers without CompressionStream) */ function packCompact(type, p, meta) { const w = new Writer(); w.u8(1); w.u8((type === 'answer' ? 1 : 0) | SETUPS.indexOf(p.setup) << 1); writeMeta(w, meta); w.str(p.mid); w.str(p.ufrag); w.str(p.pwd); w.bytes(p.fp); const cands = p.cands.slice(0, 8); w.u8(cands.length); for (const c of cands) { const ti = TYPES.indexOf(c.type); let kind, data; const u = UUID.exec(c.addr), v4 = ip4(c.addr), v6 = !v4 && ip6(c.addr); if (v4) { kind = 1; data = v4; } else if (v6) { kind = 2; data = v6; } else if (u) { kind = 3; data = (u[1] + u[2] + u[3] + u[4] + u[5]).match(/../g).map(h => parseInt(h, 16)); } else { kind = 4; data = null; } w.u8(ti << 4 | kind); if (data) w.bytes(data); else w.str(c.addr); w.u16(c.port); } return new Uint8Array(w.b); } function unpackCompact(bytes) { const r = new Reader(bytes); r.u8(); const flags = r.u8(); const type = flags & 1 ? 'answer' : 'offer', setup = SETUPS[flags >> 1 & 3]; if (!setup) throw new Error('Not a Congress Mission code.'); const meta = readMeta(r); const mid = r.str(), ufrag = r.str(), pwd = r.str(), fp = r.bytes(32); const n = r.u8(), cands = []; for (let i = 0; i < n; i++) { const k = r.u8(), type = TYPES[k >> 4], kind = k & 15; let addr; if (kind === 1) addr = Array.from(r.bytes(4)).join('.'); else if (kind === 2) addr = ip6str(r.bytes(16)); else if (kind === 3) { const h = Array.from(r.bytes(16), x => x.toString(16).padStart(2, '0')).join(''); addr = h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20) + '.local'; } else if (kind === 4) addr = r.str(); else throw new Error('Not a Congress Mission code.'); if (!type) throw new Error('Not a Congress Mission code.'); cands.push({ type, addr, port: r.u16() }); } return { type, meta, sdp: buildSdp({ ufrag, pwd, fp, setup, mid, cands }) }; } async function deflate(bytes, inflate) { const cs = inflate ? new DecompressionStream('deflate-raw') : new CompressionStream('deflate-raw'); const out = new Response(new Blob([bytes]).stream().pipeThrough(cs)); return new Uint8Array(await out.arrayBuffer()); } async function encodeSignal(type, sdp, meta) { const p = parseSdp(sdp); let bytes; if (p) bytes = packCompact(type, p, meta); else { const json = te.encode(JSON.stringify({ type, sdp, meta })); if (typeof CompressionStream === 'function') { const z = await deflate(json); bytes = new Uint8Array(z.length + 1); bytes[0] = 2; bytes.set(z, 1); } else { bytes = new Uint8Array(json.length + 1); bytes[0] = 3; bytes.set(json, 1); } } return S.b64u(bytes); } async function decodeSignal(code) { const clean = String(code || '').trim().replace(/^.*#[jr]=/, '').replace(/[^A-Za-z0-9_-]/g, ''); if (clean.length < 20) throw new Error('This is not a Congress Mission code.'); const bytes = S.unb64u(clean); if (bytes[0] === 1) return unpackCompact(bytes); if (bytes[0] === 2 || bytes[0] === 3) { const body = bytes[0] === 2 ? await deflate(bytes.slice(1), true) : bytes.slice(1); const o = JSON.parse(td.decode(body)); if (!o || typeof o.sdp !== 'string' || !o.meta) throw new Error('This is not a Congress Mission code.'); return o; } throw new Error('This code comes from a newer version of Congress Mission. Update the app on both phones.'); } /* ======================================================== mesh */ const CHUNK = 15000, HEARTBEAT = 4000, SILENT = 13000, SUSPECT = 8000, ALIVE = 9000, INVITE_TTL = 10 * 6e4; const STUN = [{ urls: 'stun:stun.l.google.com:19302' }]; class Mesh { /* opts: { store, me, mission, name: () => my display name, wide: bool (use STUN), onEvent(type, data) } */ constructor(opts) { this.store = opts.store; this.me = opts.me; this.mission = opts.mission; this.nameFn = opts.name || (() => ''); this.wide = !!opts.wide; this.onEvent = opts.onEvent || (() => {}); this.links = new Set(); this.invites = new Map(); this.dialing = new Map(); this.tries = new Map(); this.parts = new Map(); this.seenSig = new Set(); this.waiting = 0; // local changes made while no teammate was linked this.unsub = this.store.on(ev => { if (ev.origin !== 'local') return; if (this.openPeers().length) this.broadcast({ t: 'recs', recs: ev.recs }); else { this.waiting += ev.recs.length; this.emit('peers'); } }); this.timer = setInterval(() => this.tick(), HEARTBEAT); this.closed = false; } emit(type, data) { try { this.onEvent(type, data); } catch (e) { console.error(e); } } ice() { return { iceServers: this.wide ? STUN : [] }; } /* ---------- state */ openLinks() { return [...this.links].filter(l => l.open && l.peer); } openPeers() { return [...new Set(this.openLinks().map(l => l.peer))]; } /* a link is alive when the other phone answered recently and the network path is up */ alive(L) { return L.open && L.hello && !L.suspect && Date.now() - L.last < ALIVE; } liveTo(id) { return this.openLinks().some(l => l.peer === id && this.alive(l)); } /* a link to this teammate whose handshake finished after `since` (a fresh link, not an old one) */ linked(id, since) { return this.openLinks().some(l => l.peer === id && l.hello && (l.helloAt || 0) >= (since || 0)); } /* the link to use for a teammate: a live one if there is one, else the one heard from most recently */ linkTo(id) { return this.openLinks().filter(l => l.peer === id).sort((a, b) => (this.alive(b) - this.alive(a)) || (b.last - a.last))[0] || null; } /* who is reachable: directly, or through a linked teammate's own links */ reach() { const direct = new Set(this.openLinks().filter(l => l.hello && !l.suspect).map(l => l.peer)), via = new Map(); for (const l of this.openLinks()) for (const id of l.roster || []) if (id !== this.me && !direct.has(id) && !via.has(id)) via.set(id, l.peer); return { direct, via }; } /* ---------- links */ newLink(pc, o) { const L = { pc, dc: null, peer: o.peer || null, lid: o.lid || S.rid(6), via: o.via, open: false, last: Date.now(), roster: [], name: o.name || '', created: Date.now() }; this.links.add(L); const watch = st => { if (st === 'failed' || st === 'closed') return this.drop(L, st); if (st === 'disconnected') { if (!L.suspect) { L.suspect = Date.now(); this.emit('peers'); } } else if (st === 'connected' || st === 'completed') { if (L.suspect) { L.suspect = 0; L.last = Date.now(); this.emit('peers'); } } }; pc.addEventListener('connectionstatechange', () => watch(pc.connectionState)); pc.addEventListener('iceconnectionstatechange', () => watch(pc.iceConnectionState)); // Safari reports this one more reliably return L; } attach(L, dc) { L.dc = dc; const opened = () => { if (L.open || L.dropped) return; L.open = true; L.last = Date.now(); this.sayHello(L); }; dc.addEventListener('open', opened); if (dc.readyState === 'open') opened(); // the answering side can receive a channel that is already open dc.addEventListener('message', e => this.receive(L, e.data)); dc.addEventListener('close', () => this.drop(L, 'closed')); } /* hello opens every link: who I am, my mission, my links and a digest of my data. It is repeated until the other side confirms it (welcome), so one lost first message cannot leave a link half open. */ sayHello(L, n) { n = n || 0; if (L.dropped || L.welcomed || n > 8) return; this.send(L, { t: 'hello', v: 1, mission: this.mission, from: this.me, name: this.nameFn(), lid: L.lid, roster: this.openPeers().filter(p => p !== L.peer), digest: this.store.digest() }); clearTimeout(L.helloT); L.helloT = setTimeout(() => this.sayHello(L, n + 1), 1500); } drop(L, why) { if (L.dropped) return; L.dropped = true; L.open = false; clearTimeout(L.helloT); try { L.dc && L.dc.close(); } catch (e) {} try { L.pc.close(); } catch (e) {} this.links.delete(L); for (const [n, inv] of this.invites) if (inv.L === L) this.invites.delete(n); for (const [id, d] of this.dialing) if (d.L === L) this.dialing.delete(id); if (L.peer && L.hello) { if (!this.openLinks().some(x => x.peer === L.peer)) this.emit('lost', { peer: L.peer, why }); // not when a newer link replaced it this.rosterChanged(); } this.emit('peers'); } gather(pc) { if (pc.iceGatheringState === 'complete') return Promise.resolve(); return new Promise(res => { let t = 0; const done = () => { clearTimeout(t); pc.removeEventListener('icegatheringstatechange', chk); res(); }; const chk = () => { if (pc.iceGatheringState === 'complete') done(); }; pc.addEventListener('icegatheringstatechange', chk); t = setTimeout(done, this.wide ? 4000 : 2500); }); } /* ---------- pairing by codes */ async createInvite() { const pc = new RTCPeerConnection(this.ice()); const L = this.newLink(pc, { via: 'code' }); this.attach(L, pc.createDataChannel('cm', { ordered: true })); await pc.setLocalDescription(await pc.createOffer()); await this.gather(pc); const nonce = S.rand(4).reduce((a, b) => a * 256 + b, 0) >>> 0; const code = await encodeSignal('offer', pc.localDescription.sdp, { mission: this.mission, mname: this.store.val('mission', 'name', ''), member: this.me, name: this.nameFn(), nonce }); this.invites.set(nonce, { L, at: Date.now() }); return { code, nonce, link: L }; } cancelInvite(nonce) { const inv = this.invites.get(nonce); if (inv && !inv.L.open) this.drop(inv.L, 'cancelled'); this.invites.delete(nonce); } async completeInvite(code) { const s = await decodeSignal(code); if (s.type !== 'answer') throw new Error('This is an invite, not a reply. Ask your teammate to scan your code first.'); if (s.meta.mission !== this.mission) throw new Error('This reply belongs to another mission.'); const inv = this.invites.get(s.meta.nonce); if (!inv) throw new Error('This reply is for an older code. Show your current code again.'); if (this.store.removed(s.meta.member)) throw new Error('This teammate was removed from the mission.'); inv.L.peer = s.meta.member; inv.L.name = s.meta.name; await inv.L.pc.setRemoteDescription({ type: 'answer', sdp: s.sdp }); this.invites.delete(s.meta.nonce); return { member: s.meta.member, name: s.meta.name, link: inv.L }; } /* the new teammate's side: answer an invite that was already read with decodeSignal */ async answerInvite(inv) { const pc = new RTCPeerConnection(this.ice()); const L = this.newLink(pc, { via: 'code', peer: inv.meta.member, name: inv.meta.name }); pc.addEventListener('datachannel', e => this.attach(L, e.channel)); await pc.setRemoteDescription({ type: 'offer', sdp: inv.sdp }); await pc.setLocalDescription(await pc.createAnswer()); await this.gather(pc); const code = await encodeSignal('answer', pc.localDescription.sdp, { mission: this.mission, mname: '', member: this.me, name: this.nameFn(), nonce: inv.meta.nonce }); return { code, link: L }; } /* ---------- messages */ send(L, obj) { if (!L.dc || L.dc.readyState !== 'open') return false; const s = JSON.stringify(obj); try { if (s.length <= CHUNK) L.dc.send(s); else { const pid = S.rid(6), n = Math.ceil(s.length / CHUNK); for (let i = 0; i < n; i++) L.dc.send(JSON.stringify({ t: 'part', pid, i, n, d: s.slice(i * CHUNK, (i + 1) * CHUNK) })); } return true; } catch (e) { this.drop(L, 'send failed'); return false; } } broadcast(obj, except) { let n = 0; for (const id of this.openPeers()) if (id !== except) { const L = this.linkTo(id); if (L && this.send(L, obj)) n++; } return n; } sendRecs(L, recs) { L.sent = L.sent || new Map(); const out = recs.filter(r => L.sent.get(r.id) !== S.hashRec(r)); // not what this link already carried for (const r of out) L.sent.set(r.id, S.hashRec(r)); for (let i = 0; i < out.length; i += 60) this.send(L, { t: 'recs', recs: out.slice(i, i + 60) }); } rosterChanged() { const roster = this.openPeers(); for (const L of this.openLinks()) this.send(L, { t: 'roster', roster: roster.filter(p => p !== L.peer) }); } receive(L, raw) { L.last = Date.now(); let m; try { m = JSON.parse(raw); } catch (e) { return; } if (!m || typeof m.t !== 'string') return; this.handle(L, m); } handle(L, m) { if (m.t === 'part') { const k = L.lid + m.pid; let p = this.parts.get(k); if (!p) { p = { n: m.n, got: 0, d: [], at: Date.now() }; this.parts.set(k, p); } if (typeof m.i !== 'number' || m.i < 0 || m.i >= p.n || p.d[m.i] !== undefined || p.n > 2000) return; p.d[m.i] = String(m.d); p.got++; if (p.got === p.n) { this.parts.delete(k); this.receive(L, p.d.join('')); } return; } if (m.t === 'hello') return this.onHello(L, m); if (m.t === 'bye') { this.drop(L, 'bye'); return; } if (!L.hello) { // early: keep it until the handshake is done if ((L.pre = L.pre || []).length < 400) L.pre.push(m); return; } if (m.t === 'welcome') { // the other phone has our hello; its digest says what it holds now, so send anything still missing L.welcomed = true; clearTimeout(L.helloT); if (m.digest && typeof m.digest === 'object') this.sendRecs(L, this.store.diff(m.digest)); return; } if (m.t === 'ping') { this.send(L, { t: 'pong' }); return; } if (m.t === 'pong') return; if (m.t === 'recs') { const applied = this.store.merge(m.recs, L.peer); if (applied.length) this.broadcast({ t: 'recs', recs: applied }, L.peer); } else if (m.t === 'roster') { L.roster = Array.isArray(m.roster) ? m.roster.filter(x => typeof x === 'string').slice(0, 64) : []; this.emit('peers'); this.meshDial(L); } else if (m.t === 'sig') this.onSig(L, m); } onHello(L, m) { if (m.mission !== this.mission) { this.emit('wrong-mission', { name: m.name }); this.drop(L, 'other mission'); return; } if (typeof m.from !== 'string' || m.from === this.me || this.store.removed(m.from)) { this.drop(L, 'refused'); return; } this.send(L, { t: 'welcome', digest: this.store.digest() }); if (L.hello) { this.sendRecs(L, this.store.diff(m.digest)); return; } // a repeated hello: just top up L.peer = m.from; L.name = String(m.name || '').slice(0, 60); L.hello = true; L.helloAt = Date.now(); if (typeof m.lid === 'string' && m.lid < L.lid) L.lid = m.lid; // both ends agree on the link's id L.roster = Array.isArray(m.roster) ? m.roster.filter(x => typeof x === 'string').slice(0, 64) : []; // older links to the same teammate. A link made by scanning codes is a person's decision to relink, // so it replaces every older one; a dead old link is replaced in any case. Both phones see the same // new link and drop the same old ones, and the 'bye' tells the other phone in case it has not yet. for (const x of this.openLinks()) { if (x === L || x.peer !== L.peer) continue; if (L.via === 'code' || !this.alive(x)) { this.send(x, { t: 'bye' }); this.drop(x, 'replaced'); } } // two live links made by the mesh at the same moment: keep one, the same one on both sides const twins = this.openLinks().filter(x => x.peer === L.peer && x.hello); if (twins.length > 1) { twins.sort((a, b) => ((b.via === 'code') - (a.via === 'code')) || (a.lid < b.lid ? -1 : 1)); twins.slice(1).forEach(x => setTimeout(() => this.drop(x, 'duplicate'), 1500)); } this.sendRecs(L, this.store.diff(m.digest)); this.waiting = 0; this.dialing.delete(L.peer); this.tries.delete(L.peer); this.emit('linked', { peer: L.peer, name: L.name, via: L.via }); this.rosterChanged(); this.emit('peers'); this.meshDial(L); const pre = L.pre || []; L.pre = null; // messages that arrived before the handshake for (const x of pre) this.handle(L, x); } /* ---------- the mesh links itself: offers and answers travel through linked teammates */ meshDial(through) { for (const id of through.roster || []) { if (id === this.me || this.liveTo(id) || this.dialing.has(id) || this.store.removed(id)) continue; if (!(this.me < id)) continue; // the lower id calls; the other waits const t = this.tries.get(id) || { n: 0, next: 0 }; if (Date.now() < t.next) continue; t.n++; t.next = Date.now() + Math.min(120000, 5000 * 3 ** (t.n - 1)); this.tries.set(id, t); this.dial(through, id).catch(() => {}); } } async dial(through, id) { const pc = new RTCPeerConnection(this.ice()); const L = this.newLink(pc, { via: 'mesh', peer: id }); this.dialing.set(id, { L, at: Date.now() }); this.attach(L, pc.createDataChannel('cm', { ordered: true })); await pc.setLocalDescription(await pc.createOffer()); await this.gather(pc); if (L.dropped) return; this.relay({ t: 'sig', sid: S.rid(6), to: id, from: this.me, kind: 'offer', sdp: pc.localDescription.sdp, lid: L.lid, mission: this.mission, ttl: 3 }, through); setTimeout(() => { if (!L.open) this.drop(L, 'timeout'); }, 25000); } relay(m, via) { const direct = this.liveTo(m.to) && this.linkTo(m.to); if (direct) return this.send(direct, m); if (via && via.open) return this.send(via, m); return this.broadcast(m); } async onSig(L, m) { if (typeof m.sid !== 'string' || this.seenSig.has(m.sid)) return; this.seenSig.add(m.sid); if (this.seenSig.size > 500) this.seenSig = new Set([...this.seenSig].slice(-200)); if (m.to !== this.me) { // pass it on const d = this.liveTo(m.to) && this.linkTo(m.to); if (d) this.send(d, m); else if ((m.ttl | 0) > 0) this.broadcast(Object.assign({}, m, { ttl: (m.ttl | 0) - 1 }), L.peer); return; } if (m.mission !== this.mission || typeof m.from !== 'string' || this.store.removed(m.from) || typeof m.sdp !== 'string') return; if (m.kind === 'offer') { if (this.liveTo(m.from)) return; const mine = this.dialing.get(m.from); if (mine) { if (this.me < m.from) return; this.drop(mine.L, 'glare'); } const pc = new RTCPeerConnection(this.ice()); const NL = this.newLink(pc, { via: 'mesh', peer: m.from, lid: m.lid }); pc.addEventListener('datachannel', e => this.attach(NL, e.channel)); try { await pc.setRemoteDescription({ type: 'offer', sdp: m.sdp }); await pc.setLocalDescription(await pc.createAnswer()); await this.gather(pc); } catch (e) { this.drop(NL, 'bad offer'); return; } this.relay({ t: 'sig', sid: S.rid(6), to: m.from, from: this.me, kind: 'answer', sdp: pc.localDescription.sdp, lid: m.lid, mission: this.mission, ttl: 3 }, L); setTimeout(() => { if (!NL.open) this.drop(NL, 'timeout'); }, 25000); } else if (m.kind === 'answer') { const d = this.dialing.get(m.from); if (!d || d.L.lid !== m.lid || d.L.pc.signalingState !== 'have-local-offer') return; try { await d.L.pc.setRemoteDescription({ type: 'answer', sdp: m.sdp }); } catch (e) { this.drop(d.L, 'bad answer'); } } } /* ---------- upkeep */ tick() { const now = Date.now(); for (const L of [...this.links]) { if (!L.open) continue; if (now - L.last > SILENT) this.drop(L, 'silent'); else if (L.suspect && now - L.suspect > SUSPECT) this.drop(L, 'network'); else this.send(L, { t: 'ping' }); } for (const [n, inv] of this.invites) if (now - inv.at > INVITE_TTL) this.cancelInvite(n); for (const [k, p] of this.parts) if (now - p.at > 60000) this.parts.delete(k); for (const L of this.openLinks()) this.meshDial(L); } /* after the phone wakes or its network changes: ask every link for a sign of life, and drop the ones that stay quiet, so the screen shows who is really linked and Relink works straight away */ wake() { const t0 = Date.now(); for (const L of [...this.links]) if (L.open && t0 - L.last > SILENT) this.drop(L, 'slept'); for (const L of this.openLinks()) if (L.hello) this.send(L, { t: 'ping' }); clearTimeout(this.wakeT); this.wakeT = setTimeout(() => { for (const L of this.openLinks()) if (L.hello && L.last < t0) this.drop(L, 'no answer'); this.emit('peers'); }, 4500); this.emit('peers'); } offline() { for (const L of this.openLinks()) if (!L.suspect) L.suspect = Date.now(); this.emit('peers'); } close() { this.closed = true; clearInterval(this.timer); clearTimeout(this.wakeT); this.unsub(); for (const L of [...this.links]) { if (L.open) this.send(L, { t: 'bye' }); this.drop(L, 'closed'); } } } const API = { Mesh, parseSdp, buildSdp, encodeSignal, decodeSignal, packCompact, unpackCompact, ip6, ip6str, STUN }; if (typeof module === 'object' && module.exports) module.exports = API; else root.CMMesh = API;})(typeof self !== 'undefined' ? self : this);app.js1024 lines
/* Congress Mission: interface. Data model in store.js, device links in mesh.js, demo crew in demo.js. */(function () { 'use strict'; const S = window.CMStore, M = window.CMMesh; const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)]; const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const IN_FRAME = (() => { try { return window.top !== window.self; } catch (e) { return true; } })(); /* ------------------------------------------------------------ vocabulary */ const CATS = [['data', 'Data'], ['kol', 'Expert view'], ['comp', 'Competitor'], ['unmet', 'Unmet need'], ['mi', 'Med Info question'], ['other', 'Other']]; const CAT = Object.fromEntries(CATS); const TAGS = [['comp', 'Competitor data'], ['own', 'Our product'], ['kol', 'Expert talk'], ['guide', 'Guidelines'], ['sym', 'Symposium'], ['poster', 'Posters'], ['meet', 'Meeting'], ['other', 'Other']]; const TAG = Object.fromEntries(TAGS); const STATUS = [['session', 'In a session'], ['booth', 'At the booth'], ['meeting', 'In a meeting'], ['free', 'Free to help'], ['backup', 'Need backup'], ['off', 'Off duty']]; const STAT = Object.fromEntries(STATUS); const PINGS = ['Need backup here', 'Come to the booth', 'Save me a seat', 'Running late', 'Session is full', 'Call me when you can']; const PRIO = { 1: 'P1', 2: 'P2', 3: 'P3' }; /* ------------------------------------------------------------ device storage */ let storage = null; try { localStorage.setItem('cm.t', '1'); localStorage.removeItem('cm.t'); storage = localStorage; } catch (e) {} const LS = { get(k, d) { if (!storage) return d; try { const v = storage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } }, set(k, v) { if (storage) try { storage.setItem(k, JSON.stringify(v)); } catch (e) {} }, del(k) { if (storage) try { storage.removeItem(k); } catch (e) {} }, }; const prefs = Object.assign({ wake: false, notify: false, wide: false, sound: true, vibrate: true }, LS.get('cm.prefs', {})); const savePrefs = () => LS.set('cm.prefs', prefs); /* ------------------------------------------------------------ app state */ const A = { store: null, mesh: null, me: null, mid: null, demo: null, ui: null, tab: 'control', built: {}, planDay: null, planFilter: 'all', intelCat: 'all', intelFlash: false, debriefDay: null, pair: null, waitFor: null, editSes: null, editIntel: null }; const missions = () => LS.get('cm.missions', []); const saveMissions = l => LS.set('cm.missions', l); const saveUI = () => { if (A.mid && !A.demo) LS.set('cm.ui.' + A.mid, A.ui); }; /* ------------------------------------------------------------ time helpers */ const pad = n => String(n).padStart(2, '0'); const today = () => S.ymd(new Date()); const at = (day, t) => new Date(day + 'T' + (t || '00:00')); const sStart = s => at(s.day, s.start).getTime(); const sEnd = s => { const e = at(s.day, s.end).getTime(), st = sStart(s); return e > st ? e : st + 36e5; }; const fmtT = ms => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const fmtDay = day => at(day).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); const fmtDayLong = day => at(day).toLocaleDateString([], { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); function ago(ms) { const s = Math.max(0, (Date.now() - ms) / 1000); if (s < 50) return 'just now'; if (s < 3600) return Math.round(s / 60) + ' min ago'; if (s < 86400) return Math.round(s / 3600) + ' h ago'; return fmtDay(S.ymd(new Date(ms))); } function countdown(ms) { const s = Math.max(0, Math.round((ms - Date.now()) / 1000)); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + pad(m) + ' min' : m + ' min'; } /* ------------------------------------------------------------ data access */ const ST = () => A.store; const member = id => { const r = ST().get(id); return r && r.k === 'member' ? ST().obj(r) : { id, name: 'Teammate', color: 0 }; }; const members = () => ST().all('member').filter(m => !m.rm).sort((a, b) => (a.id === A.me ? -1 : b.id === A.me ? 1 : String(a.name).localeCompare(b.name))); const sessions = () => ST().all('session').sort((a, b) => sStart(a) - sStart(b) || String(a.title).localeCompare(b.title)); const insights = () => ST().all('insight').sort((a, b) => (b.at || 0) - (a.at || 0)); const msgs = () => ST().all('msg').sort((a, b) => (a.at || 0) - (b.at || 0)); const assignees = s => Object.keys(s).filter(k => k.startsWith('a.') && s[k]).map(k => k.slice(2)).filter(id => !ST().removed(id)); const mine = s => !!s['a.' + A.me]; const missionName = () => ST().val('mission', 'name', '') || (missions().find(m => m.id === A.mid) || {}).name || 'Mission'; const colorOf = id => { const c = ST().val(id, 'color', null); if (typeof c === 'number') return c & 7; let h = 0; for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0; return h % 8; }; const initials = name => String(name || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase() || '?'; const avatar = (id, big) => { const m = member(id); return `<span class="av mc${colorOf(id)}${big ? ' big' : ''}" title="${esc(m.name)}">${esc(initials(m.name))}</span>`; }; const nameOf = id => id === A.me ? 'You' : esc(member(id).name || 'Teammate'); const sessionOf = id => { const r = id && ST().get(id); return r && r.k === 'session' && !(r.f.del && r.f.del[0]) ? ST().obj(r) : null; }; function nowSession(forId) { const t = Date.now(), list = sessions().filter(s => sStart(s) <= t && t < sEnd(s)); return list.find(s => s['a.' + forId]) || null; } function insightsFor(sesId) { return insights().filter(i => i.ses === sesId); } function reach() { return A.mesh ? A.mesh.reach() : { direct: new Set(), via: new Map() }; } /* ------------------------------------------------------------ toasts, alerts, sound */ let toastT = 0; function toast(msg) { const t = $('#toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('on'), 3200); } let actx = null; function beep() { if (!prefs.sound) return; try { actx = actx || new (window.AudioContext || window.webkitAudioContext)(); const t0 = actx.currentTime; [0, 0.18].forEach((d, i) => { const o = actx.createOscillator(), g = actx.createGain(); o.type = 'square'; o.frequency.value = i ? 1320 : 880; g.gain.setValueAtTime(0.0001, t0 + d); g.gain.exponentialRampToValueAtTime(0.12, t0 + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t0 + d + 0.16); o.connect(g).connect(actx.destination); o.start(t0 + d); o.stop(t0 + d + 0.17); }); } catch (e) {} } document.addEventListener('pointerdown', () => { if (!actx && prefs.sound) try { actx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) {} }, { once: true }); function alertFlash(kind, title, text, open) { const f = $('#flash'); f.innerHTML = `<span class="fl-k">${esc(kind)}</span><b>${esc(title)}</b>${text ? `<span>${esc(text)}</span>` : ''}<button type="button" class="linkbtn" data-fl-open>Open</button><button type="button" class="x" data-fl-close aria-label="Dismiss">×</button>`; f.hidden = false; f.classList.remove('pop'); void f.offsetWidth; f.classList.add('pop'); f.querySelector('[data-fl-open]').onclick = () => { f.hidden = true; open && open(); }; f.querySelector('[data-fl-close]').onclick = () => { f.hidden = true; }; clearTimeout(alertFlash.t); alertFlash.t = setTimeout(() => { f.hidden = true; }, 20000); beep(); if (prefs.vibrate && navigator.vibrate) try { navigator.vibrate([220, 90, 220, 90, 400]); } catch (e) {} if (prefs.notify && document.hidden && 'Notification' in window && Notification.permission === 'granted' && navigator.serviceWorker) { navigator.serviceWorker.ready.then(r => r.showNotification(kind + ': ' + title, { body: text || '', tag: 'cm-' + Date.now(), icon: 'icons/icon-192.png', badge: 'icons/icon-192.png' })).catch(() => {}); } } const alerted = new Set(); function checkAlerts(recs) { const t = Date.now(); for (const d of recs) { const o = ST().obj(ST().get(d.id)); const key = d.k === 'member' ? d.id + ':' + o.sat : d.id; if (alerted.has(key) || o.by === A.me || o.del) continue; const fresh = o.at && t - o.at < 15 * 6e4; if (d.k === 'insight' && o.flash && fresh && o.head && (d.f.head || d.f.flash)) { alerted.add(d.id); alertFlash('Flash · ' + nameOf(o.by), o.head, o.sowhat || '', () => { setTab('intel'); }); } else if (d.k === 'msg' && fresh && o.text && d.f.text) { alerted.add(d.id); if (o.pri === 'flash' || o.pri === 'ping') alertFlash((o.pri === 'flash' ? 'Flash · ' : 'Ping · ') + nameOf(o.by), o.text, '', () => setTab('comms')); else if (A.tab !== 'comms') toast(member(o.by).name + ': ' + o.text.slice(0, 80)); } else if (d.k === 'member' && d.f.st && o.st === 'backup' && d.id !== A.me && o.sat && t - o.sat < 10 * 6e4) { alerted.add(key); alertFlash('Backup', member(d.id).name + ' needs backup', o.room ? 'At ' + o.room : (o.stx || ''), () => setTab('crew')); } } } /* ------------------------------------------------------------ screens */ function showScreen(id) { for (const v of ['v-welcome', 'v-new', 'v-join', 'v-app']) $('#' + v).hidden = v !== id; $('#h-link').hidden = id !== 'v-app'; $('#foot').hidden = id === 'v-app'; window.scrollTo(0, 0); } function welcome() { closeMission(); showScreen('v-welcome'); $('#w-frame').hidden = !IN_FRAME; $('#h-tag').textContent = 'Team mission control at a medical congress'; } /* ------------------------------------------------------------ mission lifecycle */ function closeMission() { if (A.unsub) A.unsub(); if (A.demo) { A.demo.stop(); A.demo = null; } if (A.mesh) A.mesh.close(); if (A.store) A.store.save(true); A.store = A.mesh = A.me = A.mid = A.unsub = null; A.built = {}; $('#demo-bar').hidden = true; stopScan(); ['#d-link', '#d-scan', '#d-session', '#d-intel', '#d-confirm'].forEach(s => { const d = $(s); if (d.open) d.close(); }); } function openMission(id, me, demo) { closeMission(); A.mid = id; A.me = me; if (demo) { A.store = new S.Store({ mission: id, me, storage: null }); A.ui = { readComms: Date.now(), readIntel: Date.now(), seen: {} }; A.demo = window.CMDemo.start(A.store, me, () => schedule()); A.mesh = A.demo.mesh; $('#demo-bar').hidden = false; } else { A.store = new S.Store({ mission: id, me, storage }); A.ui = Object.assign({ readComms: 0, readIntel: 0, seen: {} }, LS.get('cm.ui.' + id, {})); A.mesh = new M.Mesh({ store: A.store, me, mission: id, name: () => A.store.val(me, 'name', ''), wide: prefs.wide, onEvent: onMesh }); LS.set('cm.current', id); } A.unsub = A.store.on(ev => { if (ev.type === 'save-error') { toast('This phone is out of storage space for the mission. Export a backup.'); return; } if (ev.origin !== 'local') checkAlerts(ev.recs); schedule(); }); A.tab = 'control'; showScreen('v-app'); setTab('control'); wakeLock(); } function createMission(f) { const id = S.rid(9), me = S.rid(9); const list = missions().filter(m => m.id !== id); list.push({ id, name: f.name, me }); saveMissions(list); openMission(id, me); ST().set('mission', 'mission', { name: f.name, city: f.city || '', start: f.start || '', end: f.end || f.start || '', created: Date.now() }); ST().set(me, 'member', { name: f.me, role: f.role || '', joined: Date.now(), st: 'free', sat: Date.now() }); ST().save(true); toast('Mission ready. Link your teammates next.'); render(); } /* ------------------------------------------------------------ mesh events */ function onMesh(type, d) { if (type === 'linked') { const name = d.name || member(d.peer).name; A.ui.seen[d.peer] = Date.now(); saveUI(); if (A.waitFor && A.waitFor.member === d.peer) { const w = A.waitFor; A.waitFor = null; clearTimeout(w.t); w.done(name); } else toast('Linked with ' + name); } else if (type === 'lost') { A.ui.seen[d.peer] = Date.now(); saveUI(); if (!A.demo) toast('Lost the link with ' + (member(d.peer).name || 'a teammate') + '. Back on the same WiFi? Tap Relink.'); } else if (type === 'wrong-mission') toast((d.name || 'A device') + ' belongs to another mission.'); schedule(); } /* ------------------------------------------------------------ rendering */ let raf = 0; function schedule() { if (!raf) raf = requestAnimationFrame(() => { raf = 0; render(); }); } let deferred = false; function render() { if (!A.store || $('#v-app').hidden) return; const v = $('#t-' + A.tab), ae = document.activeElement; if ((A.tab === 'control' || A.tab === 'crew') && ae && v.contains(ae) && ae.matches('input,textarea,select')) { deferred = true; renderHead(); badges(); return; } deferred = false; renderHead(); const fn = { control: rControl, plan: rPlan, intel: rIntel, comms: rComms, crew: rCrew, debrief: rDebrief }[A.tab]; fn && fn(); badges(); } function renderHead() { const r = reach(), team = members().filter(m => m.id !== A.me).length; const n = r.direct.size + r.via.size; const pill = $('#h-link'); pill.classList.toggle('on', n > 0); pill.classList.toggle('demo', !!A.demo); $('#h-link-t').textContent = team === 0 ? 'Link a teammate' : n ? `${n} of ${team} linked` : 'Not linked'; $('#h-tag').textContent = missionName(); } function badges() { const nC = msgs().filter(m => m.by !== A.me && (m.at || 0) > A.ui.readComms).length; const nI = insights().filter(i => i.by !== A.me && (i.at || 0) > A.ui.readIntel).length; const b1 = $('#b-comms'), b2 = $('#b-intel'); b1.hidden = !nC || A.tab === 'comms'; b1.textContent = nC > 9 ? '9+' : nC; b2.hidden = !nI || A.tab === 'intel'; b2.textContent = nI > 9 ? '9+' : nI; } function setTab(tab) { A.tab = tab; for (const v of ['control', 'plan', 'intel', 'comms', 'crew', 'debrief']) $('#t-' + v).hidden = v !== tab; $$('#tabs [data-tab]').forEach(b => { if (b.dataset.tab === tab || (tab === 'debrief' && b.dataset.tab === 'intel')) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); }); $('#fab').hidden = tab === 'comms' || tab === 'debrief' || tab === 'crew'; if (tab === 'comms') { A.ui.readComms = Date.now(); saveUI(); } if (tab === 'intel') { A.ui.readIntel = Date.now(); saveUI(); } render(); if (tab === 'comms') { const l = $('#cm-list'); if (l) l.scrollTop = l.scrollHeight; } } document.addEventListener('focusout', () => { if (deferred) setTimeout(() => { if (deferred) schedule(); }, 0); }); /* ---------- the link graph */ function edges() { if (A.mesh.edges) return A.mesh.edges(); const out = []; for (const L of A.mesh.openLinks()) { out.push([A.me, L.peer]); for (const x of L.roster || []) out.push([L.peer, x]); } return out; } function graph() { const ms = members(), r = reach(), W = 300, H = 180, cx = W / 2, cy = H / 2; const others = ms.filter(m => m.id !== A.me), pos = { [A.me]: [cx, cy] }; others.forEach((m, i) => { const a = -Math.PI / 2 + (i / Math.max(1, others.length)) * Math.PI * 2; pos[m.id] = [cx + Math.cos(a) * 118, cy + Math.sin(a) * 64]; }); const seen = new Set(); let lines = ''; for (const [a, b] of edges()) { if (!pos[a] || !pos[b] || a === b) continue; const k = a < b ? a + b : b + a; if (seen.has(k)) continue; seen.add(k); const mineE = a === A.me || b === A.me; lines += `<line x1="${pos[a][0].toFixed(1)}" y1="${pos[a][1].toFixed(1)}" x2="${pos[b][0].toFixed(1)}" y2="${pos[b][1].toFixed(1)}" class="${mineE ? 'e-me' : 'e-via'}"/>`; } let nodes = ''; for (const m of ms) { const [x, y] = pos[m.id], st = m.id === A.me ? 'me' : r.direct.has(m.id) ? 'direct' : r.via.has(m.id) ? 'via' : 'off'; nodes += `<g class="n n-${st} mc${colorOf(m.id)}"><circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="${m.id === A.me ? 17 : 14}"/><text x="${x.toFixed(1)}" y="${(y + 4).toFixed(1)}" text-anchor="middle">${esc(initials(m.name))}</text></g>`; } return `<svg class="graph" viewBox="0 0 ${W} ${H}" role="img" aria-label="Links between your team's phones">${lines}${nodes}</svg>`; } /* ---------- Control */ function rControl() { const el = $('#t-control'), t = Date.now(), day = today(), r = reach(), ms = members(), team = ms.length - 1; const meM = member(A.me); const todays = sessions().filter(s => s.day === day); const running = todays.filter(s => sStart(s) <= t && t < sEnd(s)); const upcoming = todays.filter(s => sStart(s) > t).slice(0, 4); const myNext = sessions().find(s => mine(s) && sEnd(s) > t); const prioToday = todays.filter(s => (s.prio || 2) <= 2); const covered = prioToday.filter(s => assignees(s).length); const gaps = prioToday.filter(s => !assignees(s).length && sEnd(s) > t); const flashes = [...insights().filter(i => i.flash).map(i => ({ id: i.id, at: i.at, by: i.by, text: i.head, k: 'intel' })), ...msgs().filter(m => m.pri === 'flash').map(m => ({ id: m.id, at: m.at, by: m.by, text: m.text, k: 'comms' }))].sort((a, b) => b.at - a.at).slice(0, 3); const linked = r.direct.size + r.via.size; const offline = ms.filter(m => m.id !== A.me && !r.direct.has(m.id) && !r.via.has(m.id)); const mDates = [ST().val('mission', 'start', ''), ST().val('mission', 'end', '')]; let dayLabel = fmtDayLong(day); if (mDates[0] && mDates[1] && day >= mDates[0] && day <= mDates[1]) { const dn = Math.round((at(day) - at(mDates[0])) / 864e5) + 1, tot = Math.round((at(mDates[1]) - at(mDates[0])) / 864e5) + 1; dayLabel = `Day ${dn} of ${tot} · ` + fmtDay(day); } const sesLine = s => `<li class="ses-li${mine(s) ? ' mine' : ''}"><span class="tm">${esc(s.start)}–${esc(s.end)}</span><span class="pr p${s.prio || 2}">${PRIO[s.prio || 2]}</span><span class="tt"><b>${esc(s.title)}</b>${s.room ? `<i>${esc(s.room)}</i>` : ''}</span><span class="avs">${assignees(s).map(id => avatar(id)).join('') || '<em class="gap">nobody</em>'}</span></li>`; el.innerHTML = ` <div class="hud card"> <div class="hud-top"><span class="eyebrow">${esc(dayLabel)}</span><span class="clock">${fmtT(t)}</span></div> <h1 class="m-name">${esc(missionName())}</h1> ${myNext ? `<p class="next">${sStart(myNext) <= t ? 'Now' : 'Your next session in <b>' + countdown(sStart(myNext)) + '</b>'} · ${esc(myNext.title)}${myNext.room ? ' · ' + esc(myNext.room) : ''}</p>` : `<p class="next muted">${sessions().length ? 'No more sessions assigned to you today.' : 'Add the sessions your team must cover in Plan.'}</p>`} </div> <div class="ctl-grid"> <div class="card links"> <div class="bar"><h2 class="h2s">Links</h2>${offline.length ? '<button class="btn btn-s btn-mag" type="button" data-act="relink">Relink</button>' : '<button class="btn btn-s" type="button" data-act="link">Link a teammate</button>'}</div> ${team ? graph() : ''} <p class="lstat">${team === 0 ? 'You are the only one here so far. Link your teammates: they scan a code on your screen.' : linked ? `<b>${linked} of ${team}</b> teammates linked${r.via.size ? `, ${r.via.size} through others` : ''}.` : '<b>Not linked.</b> Everything you do is saved on this phone and goes out when a teammate links.'}</p> ${offline.length ? `<p class="relink-tip">${offline.map(m => esc(m.name)).join(', ')} ${offline.length === 1 ? 'is' : 'are'} not linked. Back on the same WiFi? Tap <b>Relink</b>: one of you shows a code, the other scans it with <b>Scan their code</b>. Linking with any one teammate is enough.</p>` : ''} ${A.mesh.waiting ? `<p class="waiting">${A.mesh.waiting} change${A.mesh.waiting === 1 ? '' : 's'} waiting to sync</p>` : ''} </div> <div class="card"> <div class="bar"><h2 class="h2s">My status</h2><span class="hint-r">${meM.sat ? 'set ' + ago(meM.sat) : ''}</span></div> <div class="chips" id="c-status">${STATUS.map(([k, v]) => `<button type="button" class="chip st-${k}" data-st="${k}" aria-pressed="${meM.st === k}">${esc(v)}</button>`).join('')}</div> <div class="room-row"><input class="input" id="c-room" maxlength="80" placeholder="Where? e.g. Hall B, booth 14" value="${esc(meM.room || '')}"><button class="btn btn-s" type="button" id="c-room-go">Set</button></div> </div> <div class="card span2"> <div class="bar"><h2 class="h2s">Now and next</h2><button class="linkbtn" type="button" data-tab-go="plan">Full plan</button></div> ${running.length || upcoming.length ? `<ul class="ses-list">${running.map(s => sesLine(s).replace('class="ses-li', 'class="ses-li now')).join('')}${upcoming.map(sesLine).join('')}</ul>` : `<p class="muted">No sessions ${todays.length ? 'left' : 'planned'} today.</p>`} </div> <div class="card"> <div class="bar"><h2 class="h2s">Coverage today</h2></div> ${prioToday.length ? `<div class="gauge"><svg viewBox="0 0 100 8" preserveAspectRatio="none" aria-hidden="true"><rect width="100" height="8" class="g-bg"/><rect width="${(100 * covered.length / prioToday.length).toFixed(1)}" height="8" class="g-fg"/></svg><b>${covered.length}/${prioToday.length}</b></div> <p class="muted small">P1 and P2 sessions with someone on them.</p> ${gaps.length ? `<ul class="gaps">${gaps.slice(0, 4).map(s => `<li><span>${esc(s.start)} · ${esc(s.title)}</span><button class="btn btn-s" type="button" data-take="${s.id}">I'll cover</button></li>`).join('')}</ul>` : '<p class="ok">Every priority session has someone on it.</p>'}` : '<p class="muted">No priority sessions today.</p>'} </div> <div class="card"> <div class="bar"><h2 class="h2s">Latest flashes</h2></div> ${flashes.length ? `<ul class="fl-list">${flashes.map(f => `<li><button type="button" class="fl-li" data-tab-go="${f.k}"><span class="tm">${fmtT(f.at)}</span>${avatar(f.by)}<span>${esc(f.text)}</span></button></li>`).join('')}</ul>` : '<p class="muted">No flashes yet. Mark big news as Flash when you capture it: every linked phone buzzes.</p>'} </div> <div class="card span2"> <div class="bar"><h2 class="h2s">Crew</h2><button class="linkbtn" type="button" data-tab-go="crew">Details</button></div> <ul class="crew-mini">${ms.map(m => crewChip(m, r)).join('')}</ul> </div> <div class="card span2 quick"> <button class="btn btn-mag" type="button" data-act="intel">Capture insight</button> <button class="btn" type="button" data-act="ping">Send a ping</button> <button class="btn" type="button" data-act="debrief">Today's debrief</button> </div> </div>`; } function crewChip(m, r) { const st = m.id === A.me ? 'me' : r.direct.has(m.id) ? 'direct' : r.via.has(m.id) ? 'via' : 'off'; const where = m.room ? ' · ' + esc(m.room) : ''; const sesNow = nowSession(m.id); return `<li class="cm-${st}">${avatar(m.id)}<span><b>${nameOf(m.id)}</b><i class="st st-${esc(m.st || 'free')}">${esc(STAT[m.st] || 'Free to help')}${where}</i>${sesNow && m.st === 'session' ? `<i class="sub">${esc(sesNow.title)}</i>` : ''}</span><em class="lk">${st === 'me' ? '' : st === 'direct' ? 'linked' : st === 'via' ? 'via ' + esc(member(r.via.get(m.id)).name) : 'offline'}</em></li>`; } /* ---------- Plan */ function planDays() { const set = new Set(sessions().map(s => s.day)); const a = ST().val('mission', 'start', ''), b = ST().val('mission', 'end', ''); if (a && b && a <= b) for (let d = at(a); S.ymd(d) <= b && set.size < 40; d.setDate(d.getDate() + 1)) set.add(S.ymd(d)); return [...set].sort(); } function clashes() { const my = sessions().filter(mine), out = new Set(); for (let i = 0; i < my.length; i++) for (let j = i + 1; j < my.length; j++) if (sStart(my[i]) < sEnd(my[j]) && sStart(my[j]) < sEnd(my[i])) { out.add(my[i].id); out.add(my[j].id); } return out; } function rPlan() { const el = $('#t-plan'); if (!A.built.plan) { el.innerHTML = `<div class="card"><div class="bar"><h1 class="h2s">Plan</h1><div class="acts-r"><button class="btn btn-s btn-mag" type="button" data-act="add-ses">Add session</button><button class="btn btn-s" type="button" data-act="import">Import agenda</button></div></div> <div class="days" id="p-days"></div> <div class="seg" id="p-filter"><button type="button" data-pf="all">All</button><button type="button" data-pf="mine">Mine</button><button type="button" data-pf="gaps">Nobody on it</button></div> <p class="hint">Import a calendar file (.ics) from the congress app, or a CSV with title, date, start, end, room and priority columns.</p></div> <div id="p-list"></div>`; A.built.plan = true; } const days = planDays(); if (!A.planDay || (!days.includes(A.planDay) && A.planDay !== 'all')) A.planDay = days.includes(today()) ? today() : days[0] || today(); $('#p-days').innerHTML = days.length ? days.map(d => `<button type="button" class="chip" data-pd="${d}" aria-pressed="${A.planDay === d}">${esc(fmtDay(d))}${d === today() ? ' · today' : ''}</button>`).join('') + `<button type="button" class="chip" data-pd="all" aria-pressed="${A.planDay === 'all'}">All days</button>` : ''; $$('#p-filter [data-pf]').forEach(b => b.setAttribute('aria-pressed', b.dataset.pf === A.planFilter)); const cl = clashes(), t = Date.now(); let list = sessions().filter(s => A.planDay === 'all' || s.day === A.planDay); if (A.planFilter === 'mine') list = list.filter(mine); if (A.planFilter === 'gaps') list = list.filter(s => !assignees(s).length); if (!list.length) { $('#p-list').innerHTML = `<div class="card empty"><p>${sessions().length ? 'No sessions match.' : 'No sessions yet. Add the ones your team must cover, or import the congress agenda.'}</p></div>`; return; } let html = '', lastDay = ''; for (const s of list) { if (A.planDay === 'all' && s.day !== lastDay) { html += `<h2 class="day-h">${esc(fmtDayLong(s.day))}</h2>`; lastDay = s.day; } const as = assignees(s), n = insightsFor(s.id).length, past = sEnd(s) < t, live = sStart(s) <= t && t < sEnd(s); html += `<article class="ses card${mine(s) ? ' mine' : ''}${past ? ' past' : ''}${live ? ' live' : ''}"> <div class="ses-t"><span class="tm">${esc(s.start)}<i>${esc(s.end)}</i></span>${live ? '<span class="live-b">LIVE</span>' : ''}</div> <div class="ses-b"> <div class="ses-h"><span class="pr p${s.prio || 2}">${PRIO[s.prio || 2]}</span>${s.tag ? `<span class="tag">${esc(TAG[s.tag] || s.tag)}</span>` : ''}${cl.has(s.id) ? '<span class="clash">Clash</span>' : ''}${s.cov ? '<span class="cov">Covered</span>' : ''}</div> <h3>${esc(s.title)}</h3> <p class="meta">${s.room ? esc(s.room) + ' · ' : ''}${as.length ? as.map(id => nameOf(id)).join(', ') : '<em class="gap">Nobody on it</em>'}${n ? ` · ${n} insight${n === 1 ? '' : 's'}` : ''}</p> ${s.note ? `<p class="note">${esc(s.note)}</p>` : ''} <div class="ses-a">${mine(s) ? `<button class="btn btn-s" type="button" data-drop="${s.id}">Drop</button>` : `<button class="btn btn-s btn-mag" type="button" data-take="${s.id}">I'll cover</button>`} <button class="btn btn-s" type="button" data-cov="${s.id}">${s.cov ? 'Not covered' : 'Mark covered'}</button> <button class="btn btn-s" type="button" data-cap="${s.id}">Insight</button> <button class="linkbtn" type="button" data-edit-ses="${s.id}">Edit</button></div> </div></article>`; } $('#p-list').innerHTML = html; } /* ---------- Intel */ function rIntel() { const el = $('#t-intel'); if (!A.built.intel) { el.innerHTML = `<div class="card"><div class="bar"><h1 class="h2s">Intel</h1><div class="acts-r"><button class="btn btn-s btn-mag" type="button" data-act="intel">Capture insight</button><button class="btn btn-s" type="button" data-act="debrief">Debrief</button></div></div> <div class="chips" id="i-cats"></div> <div class="i-tools"><label class="switch small"><input type="checkbox" id="i-flash"><span class="sw" aria-hidden="true"></span><span>Flashes only</span></label><input class="input" id="i-q" type="search" placeholder="Search insights" maxlength="80"></div></div> <div id="i-list"></div>`; $('#i-flash').addEventListener('change', e => { A.intelFlash = e.target.checked; rIntel(); }); $('#i-q').addEventListener('input', () => rIntel()); A.built.intel = true; } const all = insights(), q = $('#i-q').value.trim().toLowerCase(); $('#i-cats').innerHTML = [['all', 'All'], ...CATS].map(([k, v]) => { const n = k === 'all' ? all.length : all.filter(i => i.cat === k).length; return `<button type="button" class="chip" data-ic="${k}" aria-pressed="${A.intelCat === k}">${esc(v)} <i>${n}</i></button>`; }).join(''); let list = all.filter(i => (A.intelCat === 'all' || i.cat === A.intelCat) && (!A.intelFlash || i.flash)); if (q) list = list.filter(i => [i.head, i.body, i.sowhat, (sessionOf(i.ses) || {}).title].join(' ').toLowerCase().includes(q)); $('#i-list').innerHTML = list.length ? list.map(intelCard).join('') : `<div class="card empty"><p>${all.length ? 'No insights match.' : 'No insights yet. Capture the first one: a headline is enough.'}</p></div>`; } function intelCard(i) { const s = sessionOf(i.ses), seen = Object.keys(i).filter(k => k.startsWith('s.') && i[k] && k.slice(2) !== i.by).length; const iSaw = !!i['s.' + A.me]; return `<article class="intel card${i.flash ? ' is-flash' : ''}" id="in-${esc(i.id)}"> <div class="in-h"><span class="cat c-${esc(i.cat)}">${esc(CAT[i.cat] || 'Other')}</span>${i.flash ? '<span class="fl-b">FLASH</span>' : ''}<span class="spacer"></span><span class="tm">${i.at ? fmtT(i.at) + ' · ' + esc(fmtDay(S.ymd(new Date(i.at)))) : ''}</span></div> <h3>${esc(i.head)}</h3> ${i.body ? `<p class="body">${esc(i.body)}</p>` : ''} ${i.sowhat ? `<p class="sowhat"><b>So what:</b> ${esc(i.sowhat)}</p>` : ''} <div class="in-f">${avatar(i.by)}<span>${nameOf(i.by)}${s ? ' · ' + esc(s.title) : ''}</span><span class="spacer"></span> <span class="seen">${seen ? `Seen by ${seen}` : ''}</span> ${i.by === A.me ? `<button class="linkbtn" type="button" data-edit-in="${esc(i.id)}">Edit</button>` : iSaw ? '' : `<button class="btn btn-s" type="button" data-seen="${esc(i.id)}">Seen</button>`}</div> </article>`; } /* ---------- Comms */ function rComms() { const el = $('#t-comms'); if (!A.built.comms) { el.innerHTML = `<div class="card chat"> <div class="bar"><h1 class="h2s">Comms</h1><span class="hint-r" id="cm-state"></span></div> <ol class="cm-list" id="cm-list" aria-live="polite"></ol> <div class="pings">${PINGS.map(p => `<button type="button" class="chip" data-ping="${esc(p)}">${esc(p)}</button>`).join('')}</div> <form class="composer" id="f-msg" autocomplete="off"><input class="input" id="cm-text" maxlength="1000" placeholder="Message the team" enterkeyhint="send"><label class="switch small" title="Alert every linked phone"><input type="checkbox" id="cm-flash"><span class="sw" aria-hidden="true"></span><span>Flash</span></label><button class="btn btn-mag" type="submit">Send</button></form> </div>`; $('#f-msg').addEventListener('submit', e => { e.preventDefault(); const t = $('#cm-text').value.trim(); if (!t) return; sendMsg(t, $('#cm-flash').checked ? 'flash' : ''); $('#cm-text').value = ''; $('#cm-flash').checked = false; }); A.built.comms = true; } const list = $('#cm-list'), atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 60; const all = msgs(); let html = '', last = ''; for (const m of all) { const d = S.ymd(new Date(m.at || 0)); if (d !== last) { html += `<li class="day-sep"><span>${esc(fmtDay(d))}</span></li>`; last = d; } const me = m.by === A.me; html += `<li class="msg${me ? ' me' : ''}${m.pri ? ' pri-' + esc(m.pri) : ''}">${me ? '' : avatar(m.by)}<div class="bub"><span class="who">${me ? 'You' : esc(member(m.by).name)} · ${fmtT(m.at)}${m.pri === 'flash' ? ' · FLASH' : m.pri === 'ping' ? ' · ping' : ''}</span><p>${esc(m.text)}</p></div></li>`; } list.innerHTML = html || '<li class="empty-m">No messages yet. Pings send a one-tap alert to everyone linked.</li>'; if (atBottom || !A.built.commsScrolled) { list.scrollTop = list.scrollHeight; A.built.commsScrolled = true; } const r = reach(), n = r.direct.size + r.via.size; $('#cm-state').textContent = A.demo ? 'demo crew' : n ? `reaching ${n} teammate${n === 1 ? '' : 's'}` : 'not linked: messages wait here'; $('#cm-state').classList.toggle('warn', !n && !A.demo); } function sendMsg(text, pri) { ST().set(S.rid(9), 'msg', { text: text.slice(0, 1000), pri: pri || '', at: Date.now(), by: A.me }); if (!reach().direct.size && !A.demo) toast('Saved. It goes out when a teammate links.'); } /* ---------- Crew */ function rCrew() { const el = $('#t-crew'), r = reach(), ms = members(), meM = member(A.me); const mi = id => ST().val('mission', id, ''); const others = missions().filter(m => m.id !== A.mid); el.innerHTML = ` <div class="card"> <div class="bar"><h1 class="h2s">Crew</h1><button class="btn btn-s btn-mag" type="button" data-act="link">Link a teammate</button></div> <ul class="crew-list">${ms.map(m => { const st = m.id === A.me ? 'me' : r.direct.has(m.id) ? 'direct' : r.via.has(m.id) ? 'via' : 'off'; const seen = A.ui.seen[m.id]; return `<li class="cm-${st}">${avatar(m.id, true)}<div><b>${esc(m.name)}${m.id === A.me ? ' (you)' : ''}</b><span class="role">${esc(m.role || '')}</span> <span class="st st-${esc(m.st || 'free')}">${esc(STAT[m.st] || 'Free to help')}${m.room ? ' · ' + esc(m.room) : ''}${m.sat ? ' · ' + ago(m.sat) : ''}</span></div> <em class="lk">${st === 'me' ? 'this phone' : st === 'direct' ? 'linked directly' : st === 'via' ? 'linked through ' + esc(member(r.via.get(m.id)).name) : 'not linked' + (seen ? ' · seen ' + ago(seen) : '')}</em> ${m.id !== A.me && !A.demo ? `<span class="crew-acts">${st === 'off' ? `<button class="btn btn-s btn-mag" type="button" data-act="relink" data-who="${esc(m.id)}">Relink</button>` : ''}<button class="linkbtn danger" type="button" data-rm="${esc(m.id)}">Remove</button></span>` : ''}</li>`; }).join('')}</ul> </div> <form class="card form" id="f-me" autocomplete="off"> <h2 class="h2s">You</h2> <div class="row2"><label>Name <input class="input" id="me-name" maxlength="40" required value="${esc(meM.name || '')}"></label><label>Role <input class="input" id="me-role" maxlength="40" value="${esc(meM.role || '')}"></label></div> <div class="colors" role="radiogroup" aria-label="Your colour">${[0, 1, 2, 3, 4, 5, 6, 7].map(c => `<button type="button" class="sw-c mc${c}" data-color="${c}" aria-pressed="${colorOf(A.me) === c}" aria-label="Colour ${c + 1}"></button>`).join('')}</div> <div class="acts"><button class="btn btn-s" type="submit">Save</button></div> </form> <div class="card form"> <h2 class="h2s">This phone</h2> <label class="switch"><input type="checkbox" data-pref="wake" ${prefs.wake ? 'checked' : ''}><span class="sw" aria-hidden="true"></span><span><b>Keep the screen on</b> while the app is open. Links drop when a phone sleeps; this keeps them up, at some battery cost.</span></label> <label class="switch"><input type="checkbox" data-pref="sound" ${prefs.sound ? 'checked' : ''}><span class="sw" aria-hidden="true"></span><span><b>Sound</b> for flashes and pings</span></label> <label class="switch"><input type="checkbox" data-pref="vibrate" ${prefs.vibrate ? 'checked' : ''}><span class="sw" aria-hidden="true"></span><span><b>Vibrate</b> for flashes and pings (Android)</span></label> <label class="switch"><input type="checkbox" data-pref="notify" ${prefs.notify ? 'checked' : ''}><span class="sw" aria-hidden="true"></span><span><b>Notifications</b> for flashes while you are in another app, as long as Congress Mission is still running</span></label> <label class="switch"><input type="checkbox" data-pref="wide" ${prefs.wide ? 'checked' : ''}><span class="sw" aria-hidden="true"></span><span><b>Link across networks</b>, for teammates on mobile data or another WiFi. Each phone asks a public STUN server (Google) for its public address. Messages still go phone to phone, but that server sees your IP address. Turn it on on both phones. Some mobile networks still block it.</span></label> </div> <div class="card form"> <h2 class="h2s">Mission</h2> <form id="f-mission" class="form" autocomplete="off"> <label>Mission name <input class="input" id="mi-name" maxlength="60" value="${esc(mi('name'))}"></label> <div class="row3"><label>City <input class="input" id="mi-city" maxlength="60" value="${esc(mi('city'))}"></label><label>First day <input class="input" id="mi-start" type="date" value="${esc(mi('start'))}"></label><label>Last day <input class="input" id="mi-end" type="date" value="${esc(mi('end'))}"></label></div> <div class="acts"><button class="btn btn-s" type="submit">Save</button></div> </form> <p class="muted small">Mission data lives only on your team's phones. A backup file is the way to move it to a new phone, or to sync two phones that cannot link.</p> <div class="acts"><button class="btn btn-s" type="button" data-act="export">Export backup</button><button class="btn btn-s" type="button" data-act="import-backup">Import backup</button>${A.demo ? '' : '<button class="btn btn-s" type="button" data-act="leave">Delete from this phone</button>'}</div> ${others.length || !A.demo ? `<div class="switcher"><span class="muted small">Other missions on this phone:</span> ${others.map(m => `<button class="chip" type="button" data-open-m="${esc(m.id)}">${esc(m.name || 'Mission')}</button>`).join('') || '<span class="muted small">none</span>'} <button class="chip" type="button" data-act="new-mission">New mission</button></div>` : ''} </div>`; } /* ---------- Debrief */ function debriefData(day) { const inDay = ms => day === 'all' || S.ymd(new Date(ms || 0)) === day; const ins = insights().filter(i => inDay(i.at)).sort((a, b) => a.at - b.at); const fl = [...ins.filter(i => i.flash), ...msgs().filter(m => m.pri === 'flash' && inDay(m.at)).map(m => ({ head: m.text, by: m.by, at: m.at, msg: true }))].sort((a, b) => a.at - b.at); const ses = sessions().filter(s => day === 'all' || s.day === day); const pr = ses.filter(s => (s.prio || 2) <= 2); const reported = s => s.cov || insightsFor(s.id).length > 0; return { day, ins, fl, ses, pr, cov: pr.filter(reported), missed: pr.filter(s => !reported(s) && sEnd(s) < Date.now()), mi: ins.filter(i => i.cat === 'mi'), byCat: CATS.map(([k, v]) => [v, ins.filter(i => i.cat === k && k !== 'mi')]).filter(x => x[1].length), people: new Set(ins.map(i => i.by)).size }; } const dTitle = d => `${missionName()}: debrief, ${d.day === 'all' ? 'whole congress' : fmtDayLong(d.day)}`; function debriefMD(d) { const who = id => id === A.me ? member(A.me).name : member(id).name; const ses = i => { const s = sessionOf(i.ses); return s ? ` (${s.title}${s.room ? ', ' + s.room : ''})` : ''; }; const L = [`# ${dTitle(d)}`, '', `${d.ins.length} insight${d.ins.length === 1 ? '' : 's'} from ${d.people} teammate${d.people === 1 ? '' : 's'} · ${d.fl.length} flash${d.fl.length === 1 ? '' : 'es'} · ${d.cov.length} of ${d.pr.length} priority sessions reported`, '']; if (d.fl.length) { L.push('## Flashes', ''); for (const f of d.fl) L.push(`- **${fmtT(f.at)} · ${f.head}**${f.sowhat ? ' — ' + f.sowhat : ''} _(${who(f.by)})_`); L.push(''); } for (const [cat, list] of d.byCat) { L.push(`## ${cat}`, ''); for (const i of list) { L.push(`- **${i.head}**${ses(i)} _(${who(i.by)})_`); if (i.body) L.push(` ${i.body.replace(/\n+/g, ' ')}`); if (i.sowhat) L.push(` So what: ${i.sowhat.replace(/\n+/g, ' ')}`); } L.push(''); } if (d.mi.length) { L.push('## Questions for Medical Information', ''); for (const i of d.mi) L.push(`- ${i.head}${i.body ? ': ' + i.body.replace(/\n+/g, ' ') : ''} _(${who(i.by)})_`); L.push(''); } L.push('## Coverage', ''); if (d.cov.length) L.push('Reported: ' + d.cov.map(s => s.title).join('; ') + '.'); if (d.missed.length) L.push('', 'No report yet: ' + d.missed.map(s => `${s.title} (${fmtDay(s.day)} ${s.start})`).join('; ') + '.'); if (!d.cov.length && !d.missed.length) L.push('No priority sessions in this period.'); L.push('', '_Prepared with Congress Mission (mafflab.xyz). Insights are field observations, not verified data or CRM records._'); return L.join('\n'); } function rDebrief() { const el = $('#t-debrief'), days = planDays(); const insDays = [...new Set(insights().map(i => S.ymd(new Date(i.at || 0))))]; const all = [...new Set([...days, ...insDays])].sort(); if (!A.debriefDay) A.debriefDay = all.includes(today()) ? today() : all[all.length - 1] || today(); const d = debriefData(A.debriefDay); const who = id => nameOf(id); const ses = i => { const s = sessionOf(i.ses); return s ? `<span class="d-ses">${esc(s.title)}</span>` : ''; }; el.innerHTML = ` <div class="card no-print"><div class="bar"><h1 class="h2s">Debrief</h1><button class="linkbtn" type="button" data-tab-go="intel">Back to Intel</button></div> <div class="days">${all.map(x => `<button type="button" class="chip" data-dd="${x}" aria-pressed="${A.debriefDay === x}">${esc(fmtDay(x))}</button>`).join('')}<button type="button" class="chip" data-dd="all" aria-pressed="${A.debriefDay === 'all'}">Whole congress</button></div> <div class="acts"><button class="btn btn-s btn-mag" type="button" data-act="copy-md">Copy as text</button><button class="btn btn-s" type="button" data-act="dl-md">Download .md</button><button class="btn btn-s" type="button" data-act="dl-csv">Insights (CSV)</button><button class="btn btn-s" type="button" data-act="print">Print or PDF</button></div></div> <article class="card report" id="report"> <p class="eyebrow">Debrief</p> <h2>${esc(dTitle(d))}</h2> <div class="kv"><span><b>${d.ins.length}</b> insight${d.ins.length === 1 ? '' : 's'}</span><span><b>${d.fl.length}</b> flash${d.fl.length === 1 ? '' : 'es'}</span><span><b>${d.cov.length}/${d.pr.length}</b> priority sessions reported</span><span><b>${d.people}</b> reporting</span></div> ${d.fl.length ? `<h3>Flashes</h3><ul class="d-list">${d.fl.map(f => `<li class="d-fl"><span class="tm">${fmtT(f.at)}</span><div><b>${esc(f.head)}</b>${f.sowhat ? `<p>${esc(f.sowhat)}</p>` : ''}<i>${who(f.by)}</i></div></li>`).join('')}</ul>` : ''} ${d.byCat.map(([cat, list]) => `<h3>${esc(cat)}</h3><ul class="d-list">${list.map(i => `<li><span class="tm">${fmtT(i.at)}</span><div><b>${esc(i.head)}</b>${ses(i)}${i.body ? `<p>${esc(i.body)}</p>` : ''}${i.sowhat ? `<p class="sowhat"><b>So what:</b> ${esc(i.sowhat)}</p>` : ''}<i>${who(i.by)}</i></div></li>`).join('')}</ul>`).join('')} ${d.mi.length ? `<h3>Questions for Medical Information</h3><ul class="d-list">${d.mi.map(i => `<li><span class="tm">${fmtT(i.at)}</span><div><b>${esc(i.head)}</b>${i.body ? `<p>${esc(i.body)}</p>` : ''}<i>${who(i.by)}</i></div></li>`).join('')}</ul>` : ''} <h3>Coverage</h3> ${d.cov.length ? `<p><b>Reported:</b> ${d.cov.map(s => esc(s.title)).join('; ')}.</p>` : ''} ${d.missed.length ? `<p class="missed"><b>No report yet:</b> ${d.missed.map(s => esc(s.title) + ' (' + esc(s.start) + ')').join('; ')}.</p>` : ''} ${!d.cov.length && !d.missed.length ? '<p class="muted">No priority sessions in this period.</p>' : ''} ${!d.ins.length ? '<p class="muted">No insights captured in this period yet.</p>' : ''} <p class="muted small">Insights are field observations, not verified data or CRM records.</p> </article>`; } /* ------------------------------------------------------------ dialogs: session and insight */ function openSession(id) { const s = id ? sessionOf(id) : null; A.editSes = id || null; $('#se-title').textContent = s ? 'Edit session' : 'Add a session'; $('#se-tag').innerHTML = TAGS.map(([k, v]) => `<option value="${k}">${esc(v)}</option>`).join(''); const day = s ? s.day : (A.planDay && A.planDay !== 'all' ? A.planDay : today()); $('#se-name').value = s ? s.title : ''; $('#se-day').value = day; $('#se-start').value = s ? s.start : ''; $('#se-end').value = s ? s.end : ''; $('#se-room').value = s ? s.room || '' : ''; $('#se-prio').value = String(s ? s.prio || 2 : 1); $('#se-tag').value = s ? s.tag || 'other' : 'comp'; $('#se-note').value = s ? s.note || '' : ''; $('#se-del').hidden = !s; $('#d-session').showModal(); } function openIntel(id, sesId) { const i = id ? ST().obj(ST().get(id)) : null; A.editIntel = id || null; $('#in-title').textContent = i ? 'Edit insight' : 'Capture an insight'; const cur = i ? i.cat : 'data'; $('#in-cats').innerHTML = CATS.map(([k, v]) => `<button type="button" role="radio" class="chip c-${k}" data-cat="${k}" aria-checked="${k === cur}">${esc(v)}</button>`).join(''); const t = Date.now(); const list = sessions().filter(s => s.day === today() || (i && s.id === i.ses) || s.id === sesId); const dflt = i ? i.ses : sesId || (nowSession(A.me) || {}).id || (list.find(s => sStart(s) <= t && t < sEnd(s)) || {}).id || ''; $('#in-ses').innerHTML = '<option value="">Not tied to a session</option>' + list.map(s => `<option value="${esc(s.id)}">${esc(s.start)} · ${esc(s.title.slice(0, 70))}</option>`).join(''); $('#in-ses').value = dflt; $('#in-head').value = i ? i.head : ''; $('#in-body').value = i ? i.body || '' : ''; $('#in-sowhat').value = i ? i.sowhat || '' : ''; $('#in-flash').checked = i ? !!i.flash : false; $('#in-del').hidden = !i; $('#d-intel').showModal(); setTimeout(() => $('#in-head').focus(), 50); } function confirmBox(title, text, yes) { $('#c-title').textContent = title; $('#c-text').textContent = text; $('#c-yes').onclick = () => { $('#d-confirm').close(); yes(); }; $('#d-confirm').showModal(); } /* ------------------------------------------------------------ QR codes */ const base = () => location.origin + location.pathname.replace(/index\.html$/, ''); function qrSVG(text) { const q = window.qrcode(0, 'M'); q.addData(text, 'Byte'); q.make(); const n = q.getModuleCount(), m = 4; let d = ''; for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (q.isDark(r, c)) d += `M${c + m} ${r + m}h1v1h-1z`; return `<svg class="qr" viewBox="0 0 ${n + 2 * m} ${n + 2 * m}" shape-rendering="crispEdges" role="img" aria-label="Pairing code"><rect width="100%" height="100%" fill="#fff"/><path d="${d}" fill="#0D0D12"/></svg>`; } /* ------------------------------------------------------------ camera scanner */ let scan = null; async function startScan(onCode) { stopScan(); const d = $('#d-scan'), v = $('#s-video'), msg = $('#s-msg'); msg.textContent = 'Point the camera at the code on your teammate\'s screen.'; $('#s-paste').value = ''; $('#s-paste-go').onclick = () => { const t = $('#s-paste').value.trim(); if (t) { stopScan(); d.close(); onCode(t); } }; d.showModal(); if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { msg.textContent = 'This browser cannot use the camera here. Paste the code instead.'; return; } const me = scan = { on: true, worker: null, busy: false, stream: null }; try { me.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); } catch (e) { msg.textContent = IN_FRAME ? 'The camera is not available in this preview. Open the app in its own tab, or paste the code.' : 'No access to the camera. Allow it in the browser settings, or paste the code.'; return; } if (!me.on) { me.stream.getTracks().forEach(t => t.stop()); return; } v.srcObject = me.stream; try { await v.play(); } catch (e) {} let det = null; try { if ('BarcodeDetector' in window && (await window.BarcodeDetector.getSupportedFormats()).includes('qr_code')) det = new window.BarcodeDetector({ formats: ['qr_code'] }); } catch (e) {} const cv = document.createElement('canvas'), cx = cv.getContext('2d', { willReadFrequently: true }); const found = text => { if (!me.on) return; stopScan(); d.close(); onCode(text); }; if (!det) { me.worker = new Worker('scan-worker.js'); me.worker.onmessage = e => { me.busy = false; if (e.data) found(e.data); }; } const loop = async () => { if (!me.on) return; if (v.readyState >= 2 && v.videoWidth) { try { if (det) { const r = await det.detect(v); if (r.length && r[0].rawValue) return found(r[0].rawValue); } else if (!me.busy) { const s = Math.min(1, 720 / Math.max(v.videoWidth, v.videoHeight)); cv.width = Math.round(v.videoWidth * s); cv.height = Math.round(v.videoHeight * s); cx.drawImage(v, 0, 0, cv.width, cv.height); const img = cx.getImageData(0, 0, cv.width, cv.height); me.busy = true; me.worker.postMessage({ w: img.width, h: img.height, buf: img.data.buffer }, [img.data.buffer]); } } catch (e) {} } setTimeout(loop, det ? 150 : 90); }; loop(); } function stopScan() { if (!scan) return; scan.on = false; if (scan.stream) scan.stream.getTracks().forEach(t => t.stop()); if (scan.worker) scan.worker.terminate(); $('#s-video').srcObject = null; scan = null; } /* ------------------------------------------------------------ pairing */ const TROUBLE = `<div class="trouble"><b>Not linked yet?</b><ul> <li>Both phones must be on the <b>same WiFi</b>. Congress and hotel WiFi often stop devices from seeing each other: turn on a <b>hotspot</b> on one phone and join it with the other.</li> <li>Keep the app open on both screens while linking.</li> <li>On mobile data or different networks? Turn on <b>Link across networks</b> in Crew, on both phones.</li></ul></div>`; let linkSeq = 0; // the link sheet's current step; slower, older steps give way function openLinkDialog() { linkSeq++; const d = $('#d-link'); if (!d.open) d.showModal(); $$('#l-seg [data-lmode]').forEach(b => b.setAttribute('aria-pressed', 'false')); } function linkSheet(mode, who) { const d = $('#d-link'); linkSeq++; if (who !== undefined) $('#l-title').textContent = who === null ? 'Link a teammate' : who === '' ? 'Relink' : 'Relink with ' + (member(who).name || 'teammate'); if (!d.open) d.showModal(); $$('#l-seg [data-lmode]').forEach(b => b.setAttribute('aria-pressed', b.dataset.lmode === mode)); if (A.demo) { $('#l-body').innerHTML = `<div class="demo-pair"><p>In a real mission, this is where your teammate scans a code on your screen, and you scan their reply. Phones then link directly over the same WiFi.</p><p class="muted">The crew in this demo is simulated, so there is nothing to scan.</p></div>`; return; } if (mode === 'scan') { $('#l-body').innerHTML = `<div class="l-scan"><p>Scan the code on your teammate's screen: an invite (their first code) or the reply to yours.</p><button class="btn btn-mag" type="button" id="l-scan-go">Open camera</button></div>`; $('#l-scan-go').onclick = () => startScan(handleCode); return; } showMyCode(); } async function showMyCode() { const body = $('#l-body'); if (A.pair && A.pair.nonce != null) A.mesh.cancelInvite(A.pair.nonce); body.innerHTML = '<p class="spin">Making your code…</p>'; const seq = linkSeq; try { const inv = await A.mesh.createInvite(); if (seq !== linkSeq || !$('#d-link').open) { A.mesh.cancelInvite(inv.nonce); return; } const url = base() + '#j=' + inv.code; A.pair = { nonce: inv.nonce, url }; body.innerHTML = `<div class="qr-wrap">${qrSVG(url)}</div> <ol class="steps"><li>Your teammate scans this code with their <b>phone camera</b>, or in Congress Mission with <b>Scan their code</b>.</li><li>They show you a reply code. Scan it here:</li></ol> <div class="acts center"><button class="btn btn-mag" type="button" id="l-reply">Scan their reply</button><button class="btn btn-s" type="button" id="l-copy">Copy invite link</button></div>`; $('#l-reply').onclick = () => startScan(handleCode); $('#l-copy').onclick = () => copy(url, 'Invite link copied. Use it on a laptop, or anywhere a camera is not handy.'); } catch (e) { if (seq === linkSeq) body.innerHTML = `<p class="err">Could not make a code on this device: ${esc(e.message || e)}</p>`; } } function waitLink(memberId, name, where) { const box = where || $('#l-body'); const prev = A.waitFor; if (prev) clearTimeout(prev.t); const st = document.createElement('div'); st.className = 'linking'; st.innerHTML = `<p class="spin">Linking with ${esc(name)}…</p>`; box.appendChild(st); const since = Date.now() - 50; A.waitFor = { member: memberId, done: nm => linkedOK(nm, box) }; if (A.mesh.linked && A.mesh.linked(memberId, since)) { const w = A.waitFor; A.waitFor = null; return w.done(name); } // a fresh link only, never an old one A.waitFor.t = setTimeout(() => { if (A.waitFor && A.waitFor.member === memberId) st.innerHTML = `<p class="spin">Still trying to reach ${esc(name)}…</p>` + TROUBLE; }, 15000); } function linkedOK(name, box) { box.innerHTML = `<div class="ok-big"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12.5l5 5L20 6.5"/></svg><p>Linked with <b>${esc(name)}</b></p><p class="muted">Your phones are syncing now. Other teammates linked to either phone connect by themselves.</p> <div class="acts center"><button class="btn btn-mag" type="button" data-again>Link another teammate</button><button class="btn" type="button" data-done>Done</button></div></div>`; const again = box.querySelector('[data-again]'), done = box.querySelector('[data-done]'); if (again) again.onclick = () => { if ($('#v-app').hidden) { enterApp(); } linkSheet('show'); }; if (done) done.onclick = () => { if ($('#d-link').open) $('#d-link').close(); if ($('#v-app').hidden) enterApp(); }; schedule(); } function enterApp() { showScreen('v-app'); setTab('control'); } async function handleCode(text) { let s; try { s = await M.decodeSignal(text); } catch (e) { toast(e.message || 'That is not a Congress Mission code.'); return; } if (s.type === 'answer') { if (!A.mesh || A.demo) { toast('This is a reply code. Scan it from the phone that showed the first code.'); return; } openLinkDialog(); $('#l-body').innerHTML = ''; waitLink(s.meta.member, s.meta.name || 'your teammate'); // listen before the link can open try { await A.mesh.completeInvite(text); A.pair = null; } catch (e) { A.waitFor = null; $('#l-body').innerHTML = `<p class="err">${esc(e.message)}</p>`; } return; } // an invite if (A.mid && !A.demo && s.meta.mission === A.mid) { if (s.meta.member === A.me) { toast('That is your own code.'); return; } return replyTo(s, null); } joinFlow(s); } async function replyTo(s, where) { const box = where || $('#l-body'); if (!where) openLinkDialog(); box.innerHTML = '<p class="spin">Making your reply…</p>'; const seq = linkSeq; try { const r = await A.mesh.answerInvite(s); if (!where && seq !== linkSeq) { A.mesh.drop(r.link, 'superseded'); return; } A.lastReply = r.code; box.innerHTML = `<p class="big-step">Show this to <b>${esc(s.meta.name || 'your teammate')}</b> to scan</p><div class="qr-wrap">${qrSVG(base() + '#r=' + r.code)}</div> <details class="paste-d"><summary>Their phone has no camera?</summary><p class="muted small">Send them this reply code another way:</p><button class="btn btn-s" type="button" data-copy-r>Copy reply code</button></details>`; box.querySelector('[data-copy-r]').onclick = () => copy(r.code, 'Reply code copied.'); waitLink(s.meta.member, s.meta.name || 'your teammate', box); } catch (e) { box.innerHTML = `<p class="err">Could not answer this code: ${esc(e.message || e)}</p>`; } } /* ---------- joining a mission */ let pendingInvite = null; function joinScreen() { closeMission(); showScreen('v-join'); pendingInvite = null; $('#j-title').textContent = 'Scan your teammate\'s code'; $('#j-help').hidden = false; $('#j-acts').hidden = false; $('#j-paste').hidden = true; $('#f-join').hidden = true; $('#j-msg').innerHTML = ''; } function joinFlow(s) { const known = missions().find(m => m.id === s.meta.mission); const doJoin = () => { if (known) { openMission(known.id, known.me); return replyTo(s, null); } closeMission(); showScreen('v-join'); pendingInvite = s; $('#j-title').textContent = 'Join ' + (s.meta.mname || 'the mission'); $('#j-help').hidden = true; $('#j-acts').hidden = true; $('#j-paste').hidden = true; $('#j-msg').innerHTML = ''; $('#j-info').innerHTML = `<span class="eyebrow">Invite</span><b>${esc(s.meta.mname || 'Mission')}</b><span>from ${esc(s.meta.name || 'a teammate')}</span>`; $('#f-join').hidden = false; setTimeout(() => $('#j-me').focus(), 50); }; if (A.mid && !A.demo && A.mid !== s.meta.mission) confirmBox('Switch mission?', `This code is for "${s.meta.mname || 'another mission'}". Your current mission stays saved on this phone.`, doJoin); else doJoin(); } async function submitJoin() { const s = pendingInvite; if (!s) return; const name = $('#j-me').value.trim(); if (!name) return; const me = S.rid(9); const list = missions().filter(m => m.id !== s.meta.mission); list.push({ id: s.meta.mission, name: s.meta.mname, me }); saveMissions(list); openMission(s.meta.mission, me); ST().set(me, 'member', { name, role: $('#j-role').value.trim(), joined: Date.now(), st: 'free', sat: Date.now() }); ST().save(true); showScreen('v-join'); $('#f-join').hidden = true; $('#j-title').textContent = 'Last step'; const box = $('#j-msg'); await replyTo(s, box); } /* ------------------------------------------------------------ files */ function download(name, text, type) { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([text], { type })); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); } function copy(text, msg) { const done = () => toast(msg || 'Copied'); if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, () => fallback()); else fallback(); function fallback() { const t = document.createElement('textarea'); t.value = text; document.body.appendChild(t); t.select(); try { document.execCommand('copy'); done(); } catch (e) { toast('Could not copy'); } t.remove(); } } const slug = s => String(s || 'mission').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'mission'; const csvCell = v => { const s = String(v == null ? '' : v); return /[",\n;]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; function insightsCSV(d) { const rows = [['date', 'time', 'author', 'category', 'flash', 'headline', 'details', 'so_what', 'session', 'room']]; for (const i of d.ins) { const s = sessionOf(i.ses) || {}; rows.push([S.ymd(new Date(i.at)), fmtT(i.at), member(i.by).name, CAT[i.cat] || '', i.flash ? 'yes' : '', i.head, i.body || '', i.sowhat || '', s.title || '', s.room || '']); } return '' + rows.map(r => r.map(csvCell).join(',')).join('\r\n'); } async function importCalendar(file) { const text = await file.text(); const rows = /\.ics$/i.test(file.name) || /BEGIN:VCALENDAR/.test(text) ? S.parseICS(text) : S.parseCSV(text); if (!rows.length) { toast('No sessions found in this file. Check that it has titles, dates and start times.'); return; } const have = new Set(sessions().map(s => s.title + '|' + s.day + '|' + s.start)); let n = 0; for (const r of rows) { if (have.has(r.title + '|' + r.day + '|' + r.start)) continue; ST().set(S.rid(9), 'session', { title: r.title, day: r.day, start: r.start, end: r.end, room: r.room || '', prio: r.prio || 3, tag: r.tag && TAG[r.tag] ? r.tag : 'other', note: r.note || '' }); n++; } A.planDay = rows[0].day; toast(n ? `${n} session${n === 1 ? '' : 's'} added${rows.length > n ? `, ${rows.length - n} already in the plan` : ''}. Set priorities and who covers what.` : 'All these sessions are already in the plan.'); render(); } async function importBackup(file) { let o; try { o = JSON.parse(await file.text()); } catch (e) { toast('This is not a Congress Mission backup.'); return; } if (!o || !Array.isArray(o.recs) || typeof o.mission !== 'string') { toast('This is not a Congress Mission backup.'); return; } if (o.mission === A.mid) { const n = ST().merge(o.recs, 'backup').length; toast(n ? `Backup merged: ${n} records updated.` : 'Nothing new in this backup.'); render(); return; } const name = ((o.recs.find(r => r.id === 'mission') || {}).f || {}).name; confirmBox('Open as another mission?', `This backup is for "${name ? name[0] : 'another mission'}". It will be added to this phone as a separate mission.`, () => { const known = missions().find(m => m.id === o.mission), me = known ? known.me : S.rid(9); if (!known) { const l = missions(); l.push({ id: o.mission, name: name ? name[0] : 'Mission', me }); saveMissions(l); } openMission(o.mission, me); ST().merge(o.recs, 'backup'); if (!ST().get(me)) ST().set(me, 'member', { name: 'Me (from backup)', joined: Date.now(), st: 'free', sat: Date.now() }); toast('Mission opened from the backup. Set your name in Crew.'); render(); }); } /* ------------------------------------------------------------ wake lock */ let lock = null; async function wakeLock() { if (!prefs.wake || !('wakeLock' in navigator) || document.hidden || !A.store) { if (lock && !prefs.wake) { lock.release().catch(() => {}); lock = null; } return; } try { lock = await navigator.wakeLock.request('screen'); } catch (e) { lock = null; } } document.addEventListener('visibilitychange', () => { if (!document.hidden) { if (A.mesh && A.mesh.wake) A.mesh.wake(); wakeLock(); schedule(); } }); /* WiFi changes: check every link at once instead of waiting for it to time out */ window.addEventListener('online', () => { if (A.mesh && A.mesh.wake) A.mesh.wake(); }); window.addEventListener('offline', () => { if (A.mesh && A.mesh.offline) A.mesh.offline(); }); if (navigator.connection && navigator.connection.addEventListener) navigator.connection.addEventListener('change', () => { if (A.mesh && A.mesh.wake) A.mesh.wake(); }); /* ------------------------------------------------------------ events */ document.addEventListener('click', e => { const b = e.target.closest('button,[data-tab-go]'); if (!b) return; const ds = b.dataset; if (ds.close !== undefined) { const d = b.closest('dialog'); if (d) { if (d.id === 'd-scan') stopScan(); d.close(); } return; } if (ds.tab) return setTab(ds.tab); if (ds.tabGo) return setTab(ds.tabGo); if (ds.back !== undefined) return boot(); if (!A.store) return; if (ds.st) { const sesNow = nowSession(A.me); ST().set(A.me, 'member', { st: ds.st, sat: Date.now(), room: ds.st === 'session' && sesNow && sesNow.room ? sesNow.room : (ds.st === 'off' ? '' : member(A.me).room || '') }); if (ds.st === 'backup') toast('Your teammates get a backup alert.'); return; } if (b.id === 'c-room-go') { ST().set(A.me, 'member', { room: $('#c-room').value.trim(), sat: Date.now() }); toast('Location shared'); return; } if (ds.take) { ST().set(ds.take, 'session', { ['a.' + A.me]: true }); return; } if (ds.drop) { ST().set(ds.drop, 'session', { ['a.' + A.me]: false }); return; } if (ds.cov) { const s = sessionOf(ds.cov); ST().set(ds.cov, 'session', { cov: !(s && s.cov) }); return; } if (ds.cap) return openIntel(null, ds.cap); if (ds.editSes) return openSession(ds.editSes); if (ds.editIn) return openIntel(ds.editIn); if (ds.seen) { ST().set(ds.seen, 'insight', { ['s.' + A.me]: Date.now() }); return; } if (ds.pd) { A.planDay = ds.pd; return rPlan(); } if (ds.pf) { A.planFilter = ds.pf; return rPlan(); } if (ds.ic) { A.intelCat = ds.ic; return rIntel(); } if (ds.dd) { A.debriefDay = ds.dd; return rDebrief(); } if (ds.cat) { $$('#in-cats [data-cat]').forEach(x => x.setAttribute('aria-checked', x === b)); return; } if (ds.ping) { sendMsg(ds.ping, 'ping'); toast('Ping sent'); return; } if (ds.color) { ST().set(A.me, 'member', { color: +ds.color }); return; } if (ds.openM) { const m = missions().find(x => x.id === ds.openM); if (m) openMission(m.id, m.me); return; } if (ds.rm) { const m = member(ds.rm); return confirmBox('Remove ' + (m.name || 'this teammate') + '?', 'Their phone will no longer link to the mission, and changes they make from now on are ignored by every phone that hears about the removal.', () => { ST().set(ds.rm, 'member', { rm: true }); for (const L of A.mesh.openLinks ? A.mesh.openLinks() : []) if (L.peer === ds.rm) A.mesh.drop(L, 'removed'); }); } const act = ds.act; if (!act) return; if (act === 'link') return linkSheet('show', null); if (act === 'relink') { if (A.mesh.wake) A.mesh.wake(); return linkSheet('show', ds.who || ''); } if (act === 'intel') return openIntel(); if (act === 'add-ses') return openSession(); if (act === 'import') return $('#file-cal').click(); if (act === 'ping') { setTab('comms'); setTimeout(() => $('#cm-text') && $('#cm-text').focus(), 50); return; } if (act === 'debrief') { A.debriefDay = null; return setTab('debrief'); } if (act === 'copy-md') return copy(debriefMD(debriefData(A.debriefDay)), 'Debrief copied. Paste it into an email or a document.'); if (act === 'dl-md') return download(`${slug(missionName())}-debrief-${A.debriefDay}.md`, debriefMD(debriefData(A.debriefDay)), 'text/markdown'); if (act === 'dl-csv') return download(`${slug(missionName())}-insights-${A.debriefDay}.csv`, insightsCSV(debriefData(A.debriefDay)), 'text/csv'); if (act === 'print') return window.print(); if (act === 'export') return download(`${slug(missionName())}-backup-${today()}.json`, JSON.stringify(ST().snapshot()), 'application/json'); if (act === 'import-backup') return $('#file-backup').click(); if (act === 'new-mission') { closeMission(); return showNew(); } if (act === 'leave') return confirmBox('Delete this mission from this phone?', 'Your teammates keep their copies. Export a backup first if you might need it.', () => { const id = A.mid; ST().wipe(); LS.del('cm.ui.' + id); saveMissions(missions().filter(m => m.id !== id)); LS.del('cm.current'); A.store = null; welcome(); }); }); document.addEventListener('change', e => { const p = e.target.dataset && e.target.dataset.pref; if (!p) return; prefs[p] = e.target.checked; savePrefs(); if (p === 'wake') wakeLock(); if (p === 'wide' && A.mesh && !A.demo) { A.mesh.wide = prefs.wide; toast(prefs.wide ? 'On. Turn it on on your teammates\' phones too, then link again.' : 'Off. New links use the local network only.'); } if (p === 'notify' && prefs.notify && 'Notification' in window && Notification.permission !== 'granted') Notification.requestPermission().then(r => { if (r !== 'granted') { prefs.notify = false; savePrefs(); e.target.checked = false; toast('Notifications are blocked for this site.'); } }); }); $$('#l-seg [data-lmode]').forEach(b => b.addEventListener('click', () => linkSheet(b.dataset.lmode))); $('#d-link').addEventListener('close', () => { if (A.pair && A.pair.nonce != null && A.mesh && A.mesh.cancelInvite) { const inv = A.mesh.invites && A.mesh.invites.get(A.pair.nonce); if (inv && !inv.L.open && !inv.L.pc.remoteDescription) A.mesh.cancelInvite(A.pair.nonce); } A.pair = null; }); $('#d-scan').addEventListener('close', stopScan); $('#h-link').addEventListener('click', () => { const r = reach(), off = members().some(m => m.id !== A.me && !r.direct.has(m.id) && !r.via.has(m.id)); if (off && !A.demo) { if (A.mesh.wake) A.mesh.wake(); linkSheet('show', members().length > 1 ? '' : null); } else setTab('crew'); }); $('#fab').addEventListener('click', () => openIntel()); $('#file-cal').addEventListener('change', e => { const f = e.target.files[0]; e.target.value = ''; if (f) importCalendar(f); }); $('#file-backup').addEventListener('change', e => { const f = e.target.files[0]; e.target.value = ''; if (f) importBackup(f); }); $('#f-session').addEventListener('submit', e => { e.preventDefault(); const f = { title: $('#se-name').value.trim(), day: $('#se-day').value, start: $('#se-start').value, end: $('#se-end').value, room: $('#se-room').value.trim(), prio: +$('#se-prio').value, tag: $('#se-tag').value, note: $('#se-note').value.trim() }; if (!f.title || !f.day || !f.start) return; if (!f.end || f.end <= f.start) { const [h, m] = f.start.split(':').map(Number); f.end = pad(Math.min(23, h + 1)) + ':' + pad(m); } const id = A.editSes || S.rid(9); ST().set(id, 'session', f); A.planDay = f.day; $('#d-session').close(); }); $('#se-del').addEventListener('click', () => { if (A.editSes) ST().set(A.editSes, 'session', { del: true }); $('#d-session').close(); }); $('#f-intel').addEventListener('submit', e => { e.preventDefault(); const head = $('#in-head').value.trim(); if (!head) return; const cat = ($('#in-cats [aria-checked="true"]') || {}).dataset || {}; const f = { head, body: $('#in-body').value.trim(), sowhat: $('#in-sowhat').value.trim(), ses: $('#in-ses').value, cat: cat.cat || 'data', flash: $('#in-flash').checked }; if (!A.editIntel) Object.assign(f, { at: Date.now(), by: A.me }); ST().set(A.editIntel || S.rid(9), 'insight', f); $('#d-intel').close(); if (!A.editIntel) toast(f.flash ? 'Flash sent to every linked phone' : reach().direct.size || A.demo ? 'Shared with the team' : 'Saved. It goes out when a teammate links.'); }); $('#in-del').addEventListener('click', () => { if (A.editIntel) ST().set(A.editIntel, 'insight', { del: true }); $('#d-intel').close(); }); document.addEventListener('submit', e => { if (e.target.id === 'f-me') { e.preventDefault(); const n = $('#me-name').value.trim(); if (n) { ST().set(A.me, 'member', { name: n, role: $('#me-role').value.trim() }); toast('Saved'); } } if (e.target.id === 'f-mission') { e.preventDefault(); const name = $('#mi-name').value.trim(); ST().set('mission', 'mission', { name: name || missionName(), city: $('#mi-city').value.trim(), start: $('#mi-start').value, end: $('#mi-end').value || $('#mi-start').value }); const l = missions(); const m = l.find(x => x.id === A.mid); if (m && name) { m.name = name; saveMissions(l); } toast('Mission saved'); } }); /* welcome and setup */ function showNew() { showScreen('v-new'); const t = today(); $('#n-start').value = t; $('#n-end').value = ''; $('#n-name').focus(); } $('#w-new').addEventListener('click', showNew); $('#w-join').addEventListener('click', joinScreen); $('#w-demo').addEventListener('click', () => openMission('demo', 'demo-you-0001', true)); $('#demo-exit').addEventListener('click', () => boot()); $('#f-new').addEventListener('submit', e => { e.preventDefault(); createMission({ name: $('#n-name').value.trim(), start: $('#n-start').value, end: $('#n-end').value, city: $('#n-city').value.trim(), me: $('#n-me').value.trim(), role: $('#n-role').value.trim() }); }); $('#j-scan').addEventListener('click', () => startScan(handleCode)); $('#j-paste-b').addEventListener('click', () => { $('#j-paste').hidden = false; $('#j-paste-t').focus(); }); $('#j-paste-go').addEventListener('click', () => { const t = $('#j-paste-t').value.trim(); if (t) handleCode(t); }); $('#f-join').addEventListener('submit', e => { e.preventDefault(); submitJoin(); }); /* clock */ setInterval(() => { if (A.store && (A.tab === 'control' || A.tab === 'plan')) schedule(); }, 20000); /* install */ let installEv = null; window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); installEv = e; $('#install').hidden = false; }); $('#install').addEventListener('click', async () => { if (!installEv) return; installEv.prompt(); await installEv.userChoice.catch(() => {}); installEv = null; $('#install').hidden = true; }); if ('serviceWorker' in navigator && location.protocol !== 'file:') navigator.serviceWorker.register('sw.js').catch(() => {}); /* ------------------------------------------------------------ start */ function boot() { const h = location.hash; if (/^#j=/.test(h)) { history.replaceState(null, '', location.pathname + location.search); const cur = missions().find(m => m.id === LS.get('cm.current', '')); if (cur) openMission(cur.id, cur.me); handleCode(h); return; } if (/^#r=/.test(h)) { history.replaceState(null, '', location.pathname + location.search); welcome(); toast('This is a reply code. Scan it from inside Congress Mission on the phone that showed the first code.'); return; } if (h === '#demo') { history.replaceState(null, '', location.pathname + location.search); return openMission('demo', 'demo-you-0001', true); } const cur = missions().find(m => m.id === LS.get('cm.current', '')); if (cur && !IN_FRAME) return openMission(cur.id, cur.me); welcome(); } window.addEventListener('hashchange', () => { const h = location.hash; if (/^#j=/.test(h)) { history.replaceState(null, '', location.pathname + location.search); handleCode(h); } else if (h === '#demo') boot(); }); window.CMApp = { A, handleCode, openMission, setTab }; // for tests boot();})();demo.js105 lines
/* Congress Mission demo: a fictional congress (AURORA 2026), a fictional crew and fictional medicines (ORVENZA, VELANTAMAB). Nothing is saved or sent: the crew is simulated on this page. */(function (root) { 'use strict'; const S = root.CMStore; const CREW = [ ['demo-laura-01', 'Laura', 'MSL, thoracic', 1], ['demo-javier-1', 'Javier', 'MSL', 2], ['demo-priya-01', 'Priya', 'Medical Information', 3], ['demo-tom-0001', 'Tom', 'HEOR', 4], ]; const [LAURA, JAVIER, PRIYA, TOM] = CREW.map(c => c[0]); function start(store, me, changed) { const clocks = {}; const put = (who, id, kind, fields) => { const c = clocks[who] || (clocks[who] = new S.Clock(who)); const rec = { id, k: kind, f: {} }; for (const k in fields) rec.f[k] = [fields[k], c.tick()]; store.merge([rec], 'remote'); }; const now = Date.now(), T0 = Math.floor(now / 18e5) * 18e5, min = 6e4; const day = S.ymd(new Date(now)), plus = n => { const d = new Date(now); d.setDate(d.getDate() + n); return S.ymd(d); }; const slot = (a, b) => { const s = new Date(T0 + a * min), e = new Date(T0 + b * min); const sd = S.ymd(s); return { day: sd, start: S.hm(s), end: S.ymd(e) === sd ? S.hm(e) : '23:59' }; }; /* the mission and the crew */ store.set('mission', 'mission', { name: 'AURORA 2026', city: 'Lisbon', start: plus(-1), end: plus(2), created: now - 3 * 864e5 }); store.set(me, 'member', { name: 'Sam', role: 'Medical Lead', color: 0, st: 'free', sat: now - 20 * min, joined: now - 3 * 864e5 }); for (const [id, name, role, color] of CREW) put(id, id, 'member', { name, role, color, st: 'free', sat: now - 40 * min, joined: now - 3 * 864e5 }); put(LAURA, LAURA, 'member', { st: 'session', room: 'Hall A', sat: now - 25 * min }); put(JAVIER, JAVIER, 'member', { st: 'session', room: 'Hall C', sat: now - 12 * min }); put(PRIYA, PRIYA, 'member', { st: 'booth', room: 'Booth 14', sat: now - 50 * min }); /* today's plan, around the current time */ const ses = [ ['ses-plenary', -150, -60, 'Plenary: late-breaking abstracts 1', 'Hall A', 1, 'comp', [LAURA, JAVIER], true], ['ses-lba4', -60, 30, 'LBA 4: ORVENZA phase 3, interim overall survival', 'Hall A', 1, 'comp', [LAURA], false], ['ses-sym', -30, 60, 'Industry symposium: real-world sequencing in second line', 'Hall C', 2, 'sym', [JAVIER], false], ['ses-bio', 30, 120, 'Proffered papers: gaps in biomarker testing', 'Room 3', 2, 'kol', [], false], ['ses-own', 90, 150, 'VELANTAMAB subgroup posters: author discussion', 'Poster hall', 1, 'own', [me], false], ['ses-guide', 180, 240, 'Guideline session: updated treatment algorithm', 'Hall B', 1, 'guide', [PRIYA], false], ['ses-debate', 270, 330, 'Expert debate: when to de-escalate', 'Hall D', 3, 'kol', [], false], ]; for (const [id, a, b, title, room, prio, tag, who, cov] of ses) { const f = Object.assign({ title, room, prio, tag, note: '', cov }, slot(a, b)); for (const w of who) f['a.' + w] = true; put(LAURA, id, 'session', f); } const tomorrow = plus(1); [['ses-t1', '08:30', '10:00', 'Presidential symposium 2', 'Hall A', 1, 'comp', [LAURA, me]], ['ses-t2', '10:30', '12:00', 'ORVENZA satellite symposium', 'Hall C', 1, 'comp', [JAVIER]], ['ses-t3', '13:00', '14:00', 'Advisory board (internal)', 'Room 12', 1, 'meet', [me, TOM]], ['ses-t4', '15:00', '16:30', 'Health economics: value frameworks', 'Room 5', 2, 'other', [TOM]]].forEach(([id, st, en, title, room, prio, tag, who]) => { const f = { title, room, prio, tag, day: tomorrow, start: st, end: en, note: '' }; for (const w of who) f['a.' + w] = true; put(JAVIER, id, 'session', f); }); /* what already happened today */ put(LAURA, 'in-d1', 'insight', { cat: 'data', head: 'Plenary: competitor PFS benefit held in the elderly subgroup (HR 0.74)', body: 'Subgroup of patients 75 and older, about 18% of the trial. Discussant called it hypothesis-generating.', sowhat: 'Expect questions on our elderly data at the booth.', ses: 'ses-plenary', flash: false, at: now - 95 * min, by: LAURA, ['s.' + me]: now - 90 * min }); put(JAVIER, 'in-d2', 'insight', { cat: 'kol', head: 'Chair of the session sees sequencing as the open question for 2026', body: 'Said on stage that no trial answers what to use after progression on first-line combinations.', sowhat: 'Supports our real-world sequencing study.', ses: 'ses-plenary', flash: false, at: now - 80 * min, by: JAVIER }); put(PRIYA, 'in-d3', 'insight', { cat: 'mi', head: 'Booth: several HCPs asked about dosing in moderate renal impairment', body: 'Four questions in one hour. Not in the current FAQ.', sowhat: 'Medical Information to prepare a standard response.', ses: '', flash: false, at: now - 55 * min, by: PRIYA }); put(LAURA, 'm1', 'msg', { text: 'Morning all. Plenary starts in Hall A, I am in row 6 on the left.', pri: '', at: now - 155 * min, by: LAURA }); put(JAVIER, 'm2', 'msg', { text: 'Joining you after the poster walk.', pri: '', at: now - 150 * min, by: JAVIER }); put(PRIYA, 'm3', 'msg', { text: 'Booth is busy, could someone cover 12:30 to 13:00?', pri: '', at: now - 70 * min, by: PRIYA }); store.set('m4', 'msg', { text: 'I can, see you at the booth.', pri: '', at: now - 68 * min, by: me }); /* the next few minutes, played out by the crew */ const script = [ [5, () => put(LAURA, 'in-l1', 'insight', { cat: 'comp', head: 'LBA 4: ORVENZA interim OS HR 0.78, crossing the efficacy boundary', body: 'Median OS 21.4 vs 17.9 months. Grade 3+ adverse events 38% vs 31%. Full data at a later meeting.', sowhat: 'First OS win in this setting. Expect payer and guideline discussion; brief the launch team tonight.', ses: 'ses-lba4', flash: true, at: Date.now(), by: LAURA })], [6, () => put(JAVIER, 'm5', 'msg', { text: 'Symposium room is packed, standing only at the back.', pri: '', at: Date.now(), by: JAVIER })], [7, () => put(TOM, TOM, 'member', { st: 'booth', room: 'Booth 14', sat: Date.now() })], [6, () => put(PRIYA, 'in-l1', 'insight', { ['s.' + PRIYA]: Date.now() })], [7, () => put(JAVIER, 'in-j1', 'insight', { cat: 'unmet', head: 'Speakers agree: no data for patients after two prior lines', body: 'Three of four speakers flagged it. Audience poll: 61% "no standard of care".', sowhat: 'Strong case for an investigator-initiated study.', ses: 'ses-sym', flash: false, at: Date.now(), by: JAVIER })], [7, () => put(LAURA, 'in-l2', 'insight', { cat: 'kol', head: 'Discussant: OS benefit consistent across PD-L1 subgroups; quality of life data still pending', body: '', sowhat: 'Safety and QoL will be the competitive angle.', ses: 'ses-lba4', flash: false, at: Date.now(), by: LAURA })], [6, () => { put(JAVIER, 'ses-bio', 'session', { ['a.' + JAVIER]: true }); put(JAVIER, 'm6', 'msg', { text: 'I will take the biomarker session at ' + slot(30, 120).start + '.', pri: '', at: Date.now(), by: JAVIER }); }], [8, () => put(PRIYA, 'm7', 'msg', { text: 'Need backup here', pri: 'ping', at: Date.now(), by: PRIYA })], [6, () => put(TOM, 'm8', 'msg', { text: 'On my way to the booth, 2 minutes.', pri: '', at: Date.now(), by: TOM })], ]; let i = 0, timer = 0, stopped = false; const next = () => { if (stopped || i >= script.length) return; timer = setTimeout(() => { if (stopped) return; script[i][1](); i++; changed && changed(); next(); }, script[i][0] * 1000); }; next(); const direct = new Set([LAURA, JAVIER, TOM]), via = new Map([[PRIYA, LAURA]]); const mesh = { waiting: 0, reach: () => ({ direct, via }), edges: () => [[me, LAURA], [me, JAVIER], [me, TOM], [LAURA, PRIYA], [LAURA, JAVIER], [JAVIER, TOM]], openPeers: () => [...direct], openLinks: () => [], close() {}, }; return { mesh, stop() { stopped = true; clearTimeout(timer); } }; } root.CMDemo = { start };})(typeof self !== 'undefined' ? self : this);scan-worker.js13 lines
/* Reads QR codes from camera frames off the main thread, for browsers without a built-in barcode reader (BarcodeDetector). Frames never leave the device. */importScripts('vendor/jsQR.js');self.onmessage = e => { const { w, h, buf } = e.data; let text = null; try { const r = self.jsQR(new Uint8ClampedArray(buf), w, h, { inversionAttempts: 'attemptBoth' }); if (r && r.data) text = r.data; } catch (err) { text = null; } self.postMessage(text);};index.html181 lines
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob:; media-src 'self' blob: mediastream:; font-src 'self'; connect-src 'self'; manifest-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'"><meta name="referrer" content="no-referrer"><title>Congress Mission · your team's mission control at a medical congress</title><meta name="description" content="Plan who covers which sessions, share insights and flash alerts in real time, and leave with the debrief written. Phones link directly over the same WiFi: no server, no accounts."><meta name="theme-color" content="#0D0D12"><link rel="manifest" href="manifest.webmanifest"><link rel="icon" href="icons/icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icons/apple-touch-icon.png"><link rel="stylesheet" href="mafflab-theme.css"><link rel="stylesheet" href="style.css"><script src="store.js" defer></script><script src="mesh.js" defer></script><script src="vendor/qrcode.js" defer></script><script src="demo.js" defer></script><script src="app.js" defer></script></head><body><a class="skip" href="#main">Skip to content</a><header class="app-head"> <span class="cm-logo" aria-hidden="true"><svg viewBox="0 0 32 32"><circle cx="16" cy="16" r="12.5" fill="none" stroke="currentColor" stroke-width="1.4" opacity=".55"/><circle cx="16" cy="16" r="6.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M16 1.5v6M16 24.5v6M1.5 16h6M24.5 16h6" stroke="currentColor" stroke-width="1.6"/><circle cx="16" cy="16" r="2" fill="currentColor"/></svg></span> <a class="app-name home" href="./">Congress Mission</a> <span class="app-tag" id="h-tag">Team mission control at a medical congress</span> <span class="spacer"></span> <button class="linkpill" id="h-link" type="button" hidden aria-label="Links to teammates"><i class="dot"></i><span id="h-link-t">Not linked</span></button> <button class="btn btn-s" id="install" type="button" hidden>Install</button></header><div class="demo-bar" id="demo-bar" hidden><span><b>Demo</b> · a fictional congress and crew. Nothing is saved or sent.</span><button class="linkbtn" id="demo-exit" type="button">Leave the demo</button></div><div class="flash" id="flash" role="alert" hidden></div><main id="main" class="cm-main"> <!-- ===================== welcome ===================== --> <section class="welcome" id="v-welcome" hidden aria-labelledby="w-title"> <div class="hero card"> <p class="eyebrow">Medical Affairs · field ops</p> <h1 id="w-title">Your team's mission control<br>for a medical congress</h1> <p class="lead">Plan who covers which sessions. Share insights and flash alerts the minute they happen. Walk out of the congress with the debrief already written.</p> <ul class="pillars"> <li><b>Phone to phone.</b> Devices link directly over the same WiFi or a phone hotspot. No server, no accounts, nothing stored outside your team's phones.</li> <li><b>Paired face to face.</b> A teammate joins by scanning a code on your screen. Links are end-to-end encrypted.</li> <li><b>Works offline.</b> Everything is saved on each phone and syncs the moment teammates link again.</li> </ul> <div class="acts"> <button class="btn btn-mag" id="w-new" type="button">Start a mission</button> <button class="btn" id="w-join" type="button">Join with a code</button> <button class="btn" id="w-demo" type="button">Try the demo</button> </div> <p class="frame-note" id="w-frame" hidden>This is a preview inside the mafflab page. Open the app in its own tab to pair real phones; the demo works here.</p> </div> <div class="how grid3"> <div class="card"><span class="step">01</span><h2 class="h3">Plan</h2><p>Import the congress agenda (.ics or .csv) or add sessions. Assign must-cover sessions and see gaps and clashes at a glance.</p></div> <div class="card"><span class="step">02</span><h2 class="h3">Report</h2><p>Capture insights in seconds, tied to the session. Mark the big ones as Flash: every linked phone buzzes.</p></div> <div class="card"><span class="step">03</span><h2 class="h3">Debrief</h2><p>Each evening the report writes itself: flashes, insights by theme, questions for Medical Information, coverage.</p></div> </div> </section> <!-- ===================== new mission ===================== --> <section class="setup" id="v-new" hidden aria-labelledby="n-title"> <form class="card form" id="f-new" autocomplete="off"> <p class="eyebrow">New mission</p> <h1 id="n-title">Set up the mission</h1> <label>Mission name <input class="input" id="n-name" maxlength="60" required placeholder="e.g. ESMO 2026"></label> <div class="row2"> <label>First day <input class="input" id="n-start" type="date"></label> <label>Last day <input class="input" id="n-end" type="date"></label> </div> <label><span>City <span class="opt">optional</span></span> <input class="input" id="n-city" maxlength="60" placeholder="e.g. Berlin"></label> <hr> <label>Your name <input class="input" id="n-me" maxlength="40" required placeholder="As your team knows you"></label> <label><span>Your role <span class="opt">optional</span></span> <input class="input" id="n-role" maxlength="40" placeholder="e.g. MSL, Medical Lead"></label> <div class="acts"><button class="btn btn-mag" type="submit">Create mission</button><button class="btn" type="button" data-back>Back</button></div> </form> </section> <!-- ===================== join ===================== --> <section class="setup" id="v-join" hidden aria-labelledby="j-title"> <div class="card form" id="j-card"> <p class="eyebrow">Join a mission</p> <h1 id="j-title">Scan your teammate's code</h1> <p class="muted" id="j-help">Ask a teammate to open <b>Link</b> in Congress Mission and show you their code. Scan it here, or with your phone's camera.</p> <div class="acts" id="j-acts"><button class="btn btn-mag" id="j-scan" type="button">Scan code</button><button class="btn" id="j-paste-b" type="button">Paste code</button><button class="btn" type="button" data-back>Back</button></div> <div class="paste" id="j-paste" hidden><textarea class="input" id="j-paste-t" rows="3" placeholder="Paste the code or link"></textarea><button class="btn btn-s" id="j-paste-go" type="button">Use this code</button></div> <form id="f-join" hidden autocomplete="off"> <div class="invite-card" id="j-info"></div> <label>Your name <input class="input" id="j-me" maxlength="40" required placeholder="As your team knows you"></label> <label><span>Your role <span class="opt">optional</span></span> <input class="input" id="j-role" maxlength="40" placeholder="e.g. MSL"></label> <div class="acts"><button class="btn btn-mag" type="submit">Join and show my reply</button></div> </form> <div class="j-box" id="j-msg" aria-live="polite"></div> </div> </section> <!-- ===================== the app ===================== --> <section class="app" id="v-app" hidden> <nav class="tabs" id="tabs" aria-label="Sections"> <button type="button" data-tab="control" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="3.5"/><path d="M12 1.5v4M12 18.5v4M1.5 12h4M18.5 12h4"/></svg><span>Control</span></button> <button type="button" data-tab="plan"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4.5" width="18" height="16" rx="2"/><path d="M3 9.5h18M8 2.5v4M16 2.5v4M7 13.5h4M7 17h7"/></svg><span>Plan</span></button> <button type="button" data-tab="intel"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.5l2.6 6.2 6.4.5-4.9 4.2 1.5 6.3L12 16.4 6.4 19.7l1.5-6.3L3 9.2l6.4-.5z"/></svg><span>Intel</span><i class="badge" id="b-intel" hidden></i></button> <button type="button" data-tab="comms"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/></svg><span>Comms</span><i class="badge" id="b-comms" hidden></i></button> <button type="button" data-tab="crew"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.8-3.8 3.4-5.8 6.5-5.8s5.7 2 6.5 5.8"/><circle cx="17" cy="9" r="2.6"/><path d="M16.5 14.3c2.5.2 4.3 2 5 5"/></svg><span>Crew</span></button> </nav> <div class="view" id="t-control"></div> <div class="view" id="t-plan" hidden></div> <div class="view" id="t-intel" hidden></div> <div class="view" id="t-comms" hidden></div> <div class="view" id="t-crew" hidden></div> <div class="view" id="t-debrief" hidden></div> <button class="fab" id="fab" type="button" aria-label="Capture an insight"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span>Intel</span></button> </section></main><!-- ===================== dialogs ===================== --><dialog class="sheet" id="d-link" aria-labelledby="l-title"> <div class="sheet-head"><h2 id="l-title">Link a teammate</h2><button class="x" type="button" data-close aria-label="Close">×</button></div> <div class="seg wide" id="l-seg"><button type="button" data-lmode="show" aria-pressed="true">Show my code</button><button type="button" data-lmode="scan" aria-pressed="false">Scan their code</button></div> <div class="l-body" id="l-body"></div> <p class="l-foot muted" id="l-foot">Both phones on the same WiFi, or on one phone's hotspot. Each code works once, for 10 minutes.</p></dialog><dialog class="sheet scan-sheet" id="d-scan" aria-labelledby="s-title"> <div class="sheet-head"><h2 id="s-title">Scan a code</h2><button class="x" type="button" data-close aria-label="Close">×</button></div> <div class="viewfinder"><video id="s-video" playsinline muted></video><i class="reticle" aria-hidden="true"></i></div> <p class="muted" id="s-msg" aria-live="polite">Point the camera at the code on your teammate's screen.</p> <details class="paste-d"><summary>No camera? Paste the code</summary><textarea class="input" id="s-paste" rows="3" placeholder="Paste the code or link"></textarea><button class="btn btn-s" id="s-paste-go" type="button">Use this code</button></details></dialog><dialog class="sheet" id="d-session" aria-labelledby="se-title"> <form id="f-session" autocomplete="off"> <div class="sheet-head"><h2 id="se-title">Session</h2><button class="x" type="button" data-close aria-label="Close">×</button></div> <label>Title <input class="input" id="se-name" maxlength="300" required placeholder="e.g. LBA 3: Phase 3 results of…"></label> <div class="row3"> <label>Day <input class="input" id="se-day" type="date" required></label> <label>Start <input class="input" id="se-start" type="time" required></label> <label>End <input class="input" id="se-end" type="time" required></label> </div> <div class="row2"> <label>Room <input class="input" id="se-room" maxlength="120" placeholder="e.g. Hall A"></label> <label>Priority <select id="se-prio"><option value="1">P1 · must cover</option><option value="2">P2 · should cover</option><option value="3">P3 · if possible</option></select></label> </div> <label>Type <select id="se-tag"></select></label> <label><span>Notes <span class="opt">optional</span></span> <textarea class="input" id="se-note" rows="2" maxlength="1000"></textarea></label> <div class="acts"><button class="btn btn-mag" type="submit">Save session</button><button class="btn" type="button" id="se-del" hidden>Delete</button></div> </form></dialog><dialog class="sheet" id="d-intel" aria-labelledby="in-title"> <form id="f-intel" autocomplete="off"> <div class="sheet-head"><h2 id="in-title">Capture an insight</h2><button class="x" type="button" data-close aria-label="Close">×</button></div> <div class="cats" id="in-cats" role="radiogroup" aria-label="Kind of insight"></div> <label>Headline <input class="input" id="in-head" maxlength="200" required placeholder="One line your team can act on"></label> <label><span>Details <span class="opt">optional</span></span> <textarea class="input" id="in-body" rows="3" maxlength="3000" placeholder="Numbers, endpoints, what was said"></textarea></label> <label><span>So what? <span class="opt">optional</span></span> <textarea class="input" id="in-sowhat" rows="2" maxlength="1000" placeholder="Why it matters for us"></textarea></label> <label>Session <select id="in-ses"></select></label> <label class="switch"><input type="checkbox" id="in-flash"><span class="sw" aria-hidden="true"></span><span><b>Flash</b>: alert every linked phone now</span></label> <p class="hint">No patient data. No names of healthcare professionals unless they said it publicly on stage. Follow your company's insight SOP: this is not a CRM record.</p> <div class="acts"><button class="btn btn-mag" type="submit">Share with the team</button><button class="btn" type="button" id="in-del" hidden>Delete</button></div> </form></dialog><dialog class="sheet" id="d-confirm" aria-labelledby="c-title"> <div class="sheet-head"><h2 id="c-title">Are you sure?</h2></div> <p id="c-text"></p> <div class="acts"><button class="btn btn-mag" type="button" id="c-yes">Yes</button><button class="btn" type="button" data-close>Cancel</button></div></dialog><input type="file" id="file-cal" accept=".ics,.csv,text/calendar,text/csv" hidden><input type="file" id="file-backup" accept=".json,application/json" hidden><div class="toast" id="toast" role="status" aria-live="polite"></div><footer class="app-foot" id="foot"> <p>Congress Mission is part of <a href="../../">mafflab.xyz</a>. No cookies, no tracking, no server: mission data stays on your team's devices. An aid for congress teams, not a CRM or a regulatory record. MIT licence.</p></footer></body></html>style.css355 lines
/* Congress Mission: styles on top of mafflab-theme.css */.skip{position:absolute;left:-999px;top:8px;z-index:50;background:var(--bg-2);color:var(--white);padding:8px 12px;border:1px solid var(--cyan);border-radius:6px}.skip:focus{left:8px}.cm-logo{width:30px;height:30px;color:var(--cyan);filter:drop-shadow(0 0 6px rgba(0,240,255,.6));flex:none}.cm-logo svg{width:100%;height:100%}.app-name.home{text-decoration:none}.app-tag{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}.eyebrow{font-family:var(--f-mono);font-size:.7rem;letter-spacing:.12em;text-transform:uppercase;color:var(--cyan)}.muted{color:var(--slate)}.small{font-size:.8rem}.linkbtn{background:none;border:0;padding:0;color:var(--cyan);font:inherit;font-size:.85rem;text-decoration:underline;cursor:pointer}.linkbtn.danger{color:var(--mag)}.spacer{flex:1}.acts{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.acts.center{justify-content:center}.acts-r{display:flex;flex-wrap:wrap;gap:8px}.hint{font-size:.8rem;color:var(--slate);margin-top:10px}.hint-r{font-family:var(--f-mono);font-size:.72rem;color:var(--slate)}.hint-r.warn{color:var(--warn)}.ok{color:var(--ok);font-size:.88rem}.err{color:var(--mag)}.opt{font-family:var(--f-mono);font-weight:400;font-size:.7rem;color:var(--slate);margin-left:6px}hr{border:0;border-top:1px solid var(--line);margin:4px 0}select{appearance:auto}/* header link pill */.linkpill{display:inline-flex;align-items:center;gap:8px;font-family:var(--f-mono);font-size:.72rem;color:var(--silver);background:rgba(13,13,18,.7);border:1px solid var(--line);border-radius:999px;padding:6px 11px;cursor:pointer;white-space:nowrap}.linkpill .dot{width:8px;height:8px;border-radius:50%;background:var(--slate);box-shadow:none}.linkpill.on{border-color:rgba(59,255,158,.45);color:var(--white)}.linkpill.on .dot{background:var(--ok);box-shadow:0 0 8px var(--ok);animation:pulse 2.4s ease-in-out infinite}.linkpill.demo .dot{background:var(--warn);box-shadow:0 0 8px var(--warn)}@keyframes pulse{50%{opacity:.45}}.demo-bar{display:flex;flex-wrap:wrap;gap:6px 14px;justify-content:center;align-items:center;padding:8px var(--pad);background:rgba(244,255,90,.08);border-bottom:1px solid rgba(244,255,90,.3);font-size:.8rem;color:var(--silver)}.demo-bar b{color:var(--warn)}/* flash banner */.flash{position:fixed;left:50%;top:70px;z-index:70;width:min(560px,calc(100% - 24px));transform:translateX(-50%);display:grid;grid-template-columns:1fr auto;gap:4px 12px;padding:14px 16px;border:1px solid var(--mag);border-radius:8px;background:rgba(30,8,22,.96);box-shadow:0 0 30px rgba(255,42,133,.45);color:var(--white)}.flash.pop{animation:flashin .5s ease-out}.flash .fl-k{grid-column:1/-1;font-family:var(--f-mono);font-size:.7rem;letter-spacing:.1em;text-transform:uppercase;color:var(--mag)}.flash b{grid-column:1;font-family:var(--f-display);font-size:1.05rem;line-height:1.3}.flash span:not(.fl-k){grid-column:1;font-size:.86rem;color:var(--silver)}.flash .linkbtn{grid-column:1;justify-self:start}.flash .x{position:absolute;right:8px;top:6px}@keyframes flashin{0%{transform:translate(-50%,-30px);opacity:0}60%{transform:translate(-50%,4px);opacity:1}100%{transform:translate(-50%,0)}}.x{background:none;border:0;color:var(--silver);font-size:1.6rem;line-height:1;cursor:pointer;padding:4px 8px}.x:hover{color:var(--white)}/* layout */.cm-main{max-width:1100px;margin:0 auto;padding:clamp(16px,3vw,32px) var(--pad) 40px}.cm-main>section{display:grid;gap:18px}/* welcome */.hero{display:grid;gap:16px;padding:clamp(22px,4vw,40px)}.hero h1{font-size:clamp(1.8rem,5vw,3rem)}.lead{font-size:1.08rem;color:var(--white);max-width:60ch}.pillars{list-style:none;margin:0;padding:0;display:grid;gap:10px;max-width:70ch}.pillars li{padding-left:22px;position:relative}.pillars li::before{content:"";position:absolute;left:2px;top:.6em;width:8px;height:8px;border-radius:50%;background:var(--cyan);box-shadow:0 0 8px var(--cyan)}.pillars b{color:var(--white)}.frame-note{font-size:.85rem;color:var(--warn)}.grid3{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}.step{font-family:var(--f-mono);font-size:.75rem;color:var(--mag)}.h3{font-size:1.15rem;margin:6px 0 8px}/* forms */.form{display:grid;gap:14px}.form label{display:grid;gap:6px}.setup .form{max-width:620px}.setup h1{font-size:clamp(1.5rem,4vw,2.1rem)}.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}.row3{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.paste{display:grid;gap:8px}.invite-card{display:grid;gap:2px;padding:14px;border:1px solid var(--line-m);border-radius:var(--r);background:rgba(255,42,133,.07)}.invite-card b{font-family:var(--f-display);font-size:1.3rem;color:var(--white)}.invite-card span:last-child{color:var(--silver);font-size:.9rem}#j-msg:empty{display:none}/* switches */.switch{display:grid!important;grid-template-columns:auto 1fr;gap:12px!important;align-items:start;font-family:var(--f-body)!important;font-weight:400!important;color:var(--silver)!important;font-size:.88rem!important;cursor:pointer}.switch input{position:absolute;opacity:0;width:1px;height:1px}.switch .sw{width:40px;height:22px;border-radius:999px;background:rgba(203,213,225,.15);border:1px solid var(--line);position:relative;transition:background .2s;margin-top:1px}.switch .sw::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--silver);transition:transform .2s}.switch input:checked+.sw{background:rgba(255,42,133,.35);border-color:var(--mag)}.switch input:checked+.sw::after{transform:translateX(18px);background:var(--white)}.switch input:focus-visible+.sw{outline:2px solid var(--cyan);outline-offset:2px}.switch b{color:var(--white)}.switch.small{font-size:.8rem!important;align-items:center}/* tabs */.tabs{position:fixed;left:0;right:0;bottom:0;z-index:20;display:grid;grid-template-columns:repeat(5,1fr);background:rgba(13,13,18,.94);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-top:1px solid var(--line-c);padding-bottom:env(safe-area-inset-bottom)}.tabs button{position:relative;display:grid;justify-items:center;gap:3px;padding:9px 2px 8px;background:none;border:0;color:var(--slate);font-family:var(--f-mono);font-size:.66rem;cursor:pointer}.tabs svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round;stroke-linecap:round}.tabs button[aria-current="page"]{color:var(--cyan)}.tabs button[aria-current="page"] svg{filter:drop-shadow(0 0 5px rgba(0,240,255,.7))}.tabs .badge{position:absolute;top:5px;left:calc(50% + 6px);min-width:17px;height:17px;padding:0 4px;border-radius:9px;border:0;background:var(--mag);color:var(--white);font-size:.62rem;font-style:normal;display:grid;place-items:center;box-shadow:0 0 8px rgba(255,42,133,.7)}.app{padding-bottom:84px}.view{display:grid;gap:16px}.fab{position:fixed;right:16px;bottom:calc(78px + env(safe-area-inset-bottom));z-index:21;display:flex;align-items:center;gap:6px;padding:13px 18px 13px 14px;border-radius:999px;border:1px solid var(--mag);background:var(--mag);color:var(--bg);font-family:var(--f-mono);font-weight:700;font-size:.8rem;box-shadow:0 0 22px rgba(255,42,133,.55);cursor:pointer}.fab svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round}@media (min-width:900px){ .tabs{position:sticky;top:61px;bottom:auto;grid-template-columns:repeat(5,auto);justify-content:start;gap:4px;border:1px solid var(--line);border-radius:var(--r);padding:4px;margin-bottom:4px;background:rgba(13,13,18,.85)} .tabs button{grid-auto-flow:column;gap:8px;padding:9px 16px;font-size:.78rem;border-radius:5px} .tabs svg{width:18px;height:18px} .tabs button[aria-current="page"]{background:rgba(0,240,255,.1);box-shadow:inset 0 0 0 1px var(--line-c)} .tabs .badge{position:static} .app{padding-bottom:0} .fab{bottom:28px;right:28px}}/* avatars and member colours */.mc0{--mc:#00F0FF}.mc1{--mc:#FF2A85}.mc2{--mc:#F4FF5A}.mc3{--mc:#3BFF9E}.mc4{--mc:#FF9F40}.mc5{--mc:#B36BFF}.mc6{--mc:#4D8DFF}.mc7{--mc:#FF6BD6}.av{display:inline-grid;place-items:center;flex:none;width:26px;height:26px;border-radius:50%;font-family:var(--f-mono);font-size:.62rem;font-weight:700;color:var(--bg);background:var(--mc,#00F0FF);box-shadow:0 0 0 2px var(--bg),0 0 8px color-mix(in srgb,var(--mc,#00F0FF) 55%,transparent)}.av.big{width:40px;height:40px;font-size:.8rem}.avs{display:flex;gap:2px}/* chips */.chips{display:flex;flex-wrap:wrap;gap:8px}.chip{font-family:var(--f-mono);font-size:.74rem;padding:7px 11px;border-radius:999px;border:1px solid var(--line-c);background:rgba(13,13,18,.6);color:var(--silver);cursor:pointer;white-space:nowrap}.chip:hover{color:var(--white);border-color:var(--cyan)}.chip[aria-pressed="true"],.chip[aria-checked="true"]{background:rgba(0,240,255,.14);color:var(--white);border-color:var(--cyan);box-shadow:0 0 10px rgba(0,240,255,.25)}.chip i{font-style:normal;color:var(--slate);margin-left:2px}.chip.st-backup[aria-pressed="true"]{background:rgba(255,42,133,.2);border-color:var(--mag);box-shadow:0 0 12px rgba(255,42,133,.4)}.days{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px;margin-bottom:12px;scrollbar-width:thin}/* status words */.st{font-style:normal;font-family:var(--f-mono);font-size:.72rem;color:var(--silver)}.st-session{color:var(--cyan)}.st-booth{color:var(--ok)}.st-meeting{color:#B36BFF}.st-free{color:var(--silver)}.st-backup{color:var(--mag)}.st-off{color:var(--slate)}/* control */.hud{position:relative;overflow:hidden;display:grid;gap:6px;border-color:rgba(255,42,133,.35);background:linear-gradient(135deg,rgba(255,42,133,.1),rgba(122,0,255,.08) 50%,rgba(0,240,255,.06)),var(--glass)}.hud::after{content:"";position:absolute;inset:0;pointer-events:none;background:repeating-linear-gradient(0deg,rgba(255,255,255,.025) 0 1px,transparent 1px 3px)}.hud-top{display:flex;justify-content:space-between;align-items:center;gap:10px}.clock{font-family:var(--f-mono);font-size:1rem;color:var(--white);text-shadow:0 0 8px rgba(0,240,255,.7)}.m-name{font-size:clamp(1.6rem,5vw,2.4rem);text-shadow:-2px 0 rgba(255,42,133,.6),2px 0 rgba(0,240,255,.45)}.next{color:var(--white)}.next b{color:var(--warn);font-family:var(--f-mono)}.ctl-grid{display:grid;grid-template-columns:1fr;gap:16px}.ctl-grid>*{min-width:0}@media (min-width:900px){.ctl-grid{grid-template-columns:1fr 1fr}.span2{grid-column:1/-1}}.graph{width:100%;max-width:420px;height:auto;display:block;margin:0 auto 6px}.graph line{stroke-width:1.6}.graph .e-me{stroke:var(--cyan);filter:drop-shadow(0 0 3px rgba(0,240,255,.8))}.graph .e-via{stroke:#B36BFF;stroke-dasharray:4 4;opacity:.8}.graph .n circle{fill:var(--bg);stroke:var(--mc);stroke-width:2}.graph .n text{fill:var(--mc);font-family:var(--f-mono);font-size:10px;font-weight:700}.graph .n-me circle{fill:var(--mc);stroke:var(--white)}.graph .n-me text{fill:var(--bg)}.graph .n-direct circle{filter:drop-shadow(0 0 5px var(--mc))}.graph .n-off{opacity:.32}.lstat{font-size:.88rem}.lstat b{color:var(--white)}.waiting{margin-top:6px;font-family:var(--f-mono);font-size:.74rem;color:var(--warn)}.room-row{display:flex;gap:8px;margin-top:12px}.room-row .input{padding:9px 12px}.ses-list{list-style:none;margin:0;padding:0;display:grid;gap:2px}.ses-li{display:grid;grid-template-columns:auto auto 1fr auto;gap:10px;align-items:center;padding:9px 8px;border-radius:5px;border-left:2px solid transparent}.ses-li .tt{min-width:0;display:grid}.ses-li .tt b{color:var(--white);font-weight:500;font-size:.9rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ses-li .tt i{font-style:normal;font-size:.75rem;color:var(--slate)}.ses-li.now{background:rgba(0,240,255,.07);border-left-color:var(--cyan)}.ses-li.mine .tt b::after{content:" · you";color:var(--warn);font-family:var(--f-mono);font-size:.7rem}.tm{font-family:var(--f-mono);font-size:.74rem;color:var(--silver);white-space:nowrap}.pr{font-family:var(--f-mono);font-size:.64rem;padding:2px 6px;border-radius:3px;border:1px solid}.pr.p1{color:var(--mag);border-color:var(--line-m)}.pr.p2{color:var(--warn);border-color:rgba(244,255,90,.35)}.pr.p3{color:var(--slate);border-color:var(--line)}.gap{font-style:normal;font-family:var(--f-mono);font-size:.72rem;color:var(--mag)}.gauge{display:flex;align-items:center;gap:12px}.gauge svg{flex:1;height:10px;border-radius:5px;overflow:hidden}.gauge .g-bg{fill:rgba(203,213,225,.12)}.gauge .g-fg{fill:var(--ok)}.gauge b{font-family:var(--f-display);font-size:1.4rem;color:var(--white)}.gaps{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:8px}.gaps li{display:flex;gap:10px;align-items:center;justify-content:space-between;font-size:.86rem}.gaps li span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gaps .btn,.ses-a .btn,.quick .btn{flex:none;white-space:nowrap}.fl-list{list-style:none;margin:0;padding:0;display:grid;gap:6px}.fl-li{width:100%;display:flex;gap:10px;align-items:center;text-align:left;background:rgba(255,42,133,.07);border:1px solid var(--line-m);border-radius:5px;padding:9px 10px;color:var(--white);font:inherit;font-size:.86rem;cursor:pointer}.fl-li span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.crew-mini{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.crew-mini li{display:flex;gap:10px;align-items:center;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:rgba(13,13,18,.5);min-width:0}.crew-mini li>span:not(.av){display:grid;min-width:0;flex:1}.crew-mini b{color:var(--white);font-size:.88rem}.crew-mini .sub{font-style:normal;font-size:.72rem;color:var(--slate);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.lk{font-style:normal;font-family:var(--f-mono);font-size:.66rem;color:var(--slate);white-space:nowrap}.cm-direct .lk{color:var(--ok)}.cm-via .lk{color:#B36BFF}.cm-off{opacity:.65}.quick{display:flex;flex-wrap:wrap;gap:10px}/* plan */.day-h{font-size:1rem;margin:10px 0 0;color:var(--cyan);font-family:var(--f-mono);font-weight:400}.ses{display:grid;grid-template-columns:64px 1fr;gap:14px;padding:16px}.ses.mine{border-color:rgba(244,255,90,.4)}.ses.live{border-color:var(--cyan);box-shadow:var(--glow-c)}.ses.past{opacity:.6}.ses-t{display:grid;align-content:start;gap:6px}.ses-t .tm{font-size:.95rem;color:var(--white);display:grid}.ses-t .tm i{font-style:normal;font-size:.75rem;color:var(--slate)}.live-b{font-family:var(--f-mono);font-size:.62rem;color:var(--bg);background:var(--cyan);border-radius:3px;padding:2px 5px;justify-self:start;animation:pulse 1.6s infinite}.ses-b{display:grid;gap:6px;min-width:0}.ses-h{display:flex;flex-wrap:wrap;gap:6px;align-items:center}.ses h3{font-size:1.02rem;font-family:var(--f-body);font-weight:600;line-height:1.35}.tag,.clash,.cov{font-family:var(--f-mono);font-size:.64rem;padding:2px 6px;border-radius:3px;border:1px solid var(--line);color:var(--silver)}.clash{color:var(--mag);border-color:var(--line-m)}.cov{color:var(--ok);border-color:rgba(59,255,158,.4)}.meta{font-size:.82rem;color:var(--slate)}.note{font-size:.84rem;color:var(--silver);white-space:pre-wrap}.ses-a{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:4px}.empty p{color:var(--slate)}/* intel */.i-tools{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-top:12px}.i-tools .input{flex:1;min-width:180px;padding:9px 12px}#i-list,#p-list{display:grid;gap:12px}.intel{display:grid;gap:8px}.intel.is-flash{border-color:var(--mag);box-shadow:0 0 18px rgba(255,42,133,.22)}.in-h{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.cat{font-family:var(--f-mono);font-size:.66rem;padding:3px 7px;border-radius:3px;border:1px solid currentColor}.c-data{--cc:#00F0FF}.c-kol{--cc:#B36BFF}.c-comp{--cc:#FF2A85}.c-unmet{--cc:#F4FF5A}.c-mi{--cc:#3BFF9E}.c-other{--cc:#CBD5E1}.c-data,.c-kol,.c-comp,.c-unmet,.c-mi,.c-other{color:var(--cc)}.chip.c-data,.chip.c-kol,.chip.c-comp,.chip.c-unmet,.chip.c-mi,.chip.c-other{border-color:currentColor}.fl-b{font-family:var(--f-mono);font-size:.62rem;padding:3px 7px;border-radius:3px;background:var(--mag);color:var(--white);box-shadow:0 0 10px rgba(255,42,133,.6)}.intel h3{font-size:1.05rem;font-family:var(--f-body);font-weight:600;line-height:1.35}.intel .body{white-space:pre-wrap;font-size:.9rem}.sowhat{font-size:.88rem;color:var(--white);border-left:2px solid var(--warn);padding-left:10px}.sowhat b{color:var(--warn);font-family:var(--f-mono);font-size:.72rem;font-weight:400;text-transform:uppercase;letter-spacing:.06em}.in-f{display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:.8rem;color:var(--slate)}.seen{font-family:var(--f-mono);font-size:.7rem;color:var(--ok)}/* comms */.chat{display:grid;grid-template-rows:auto 1fr auto auto;gap:12px;height:calc(100vh - 210px);height:calc(100dvh - 210px);min-height:420px}@media (min-width:900px){.chat{height:calc(100dvh - 190px)}}.cm-list{list-style:none;margin:0;padding:4px 2px;overflow-y:auto;display:flex;flex-direction:column;gap:10px;min-height:0;overscroll-behavior:contain}.day-sep{text-align:center;font-family:var(--f-mono);font-size:.68rem;color:var(--slate)}.day-sep span{padding:2px 10px;border:1px solid var(--line);border-radius:999px}.msg{display:flex;gap:8px;align-items:flex-end;max-width:85%}.msg.me{align-self:flex-end;flex-direction:row-reverse}.bub{padding:9px 12px;border-radius:12px 12px 12px 3px;background:rgba(203,213,225,.08);border:1px solid var(--line);min-width:0}.msg.me .bub{border-radius:12px 12px 3px 12px;background:rgba(0,240,255,.1);border-color:var(--line-c)}.bub .who{display:block;font-family:var(--f-mono);font-size:.64rem;color:var(--slate);margin-bottom:2px}.bub p{color:var(--white);font-size:.92rem;white-space:pre-wrap;overflow-wrap:anywhere}.msg.pri-flash .bub{background:rgba(255,42,133,.16);border-color:var(--mag);box-shadow:0 0 14px rgba(255,42,133,.3)}.msg.pri-ping .bub{border-color:rgba(244,255,90,.5);background:rgba(244,255,90,.07)}.msg.pri-ping .bub p::before{content:"◆ ";color:var(--warn)}.empty-m{color:var(--slate);font-size:.88rem;text-align:center;margin:auto}.pings{display:flex;gap:8px;overflow-x:auto;padding-bottom:2px;scrollbar-width:thin}.pings .chip{border-color:rgba(244,255,90,.35)}.composer{display:flex;gap:8px;align-items:center}.composer .input{flex:1;min-width:0}.composer .switch{display:flex!important;gap:6px!important}/* crew */.crew-list{list-style:none;margin:0;padding:0;display:grid;gap:10px}.crew-list li{display:grid;grid-template-columns:auto 1fr auto;gap:4px 12px;align-items:center;padding:12px;border:1px solid var(--line);border-radius:6px;background:rgba(13,13,18,.5)}.crew-list li>div{display:grid;min-width:0}.crew-list b{color:var(--white)}.crew-list .role{font-size:.8rem;color:var(--slate)}.crew-list .lk{grid-column:2/-1}.crew-list .linkbtn{grid-column:3;grid-row:1;font-size:.75rem}.colors{display:flex;gap:10px;flex-wrap:wrap}.sw-c{width:30px;height:30px;border-radius:50%;border:2px solid var(--bg);background:var(--mc);cursor:pointer;box-shadow:0 0 0 1px var(--line)}.sw-c[aria-pressed="true"]{box-shadow:0 0 0 2px var(--white),0 0 12px var(--mc)}.switcher{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:6px}/* debrief */.report{display:grid;gap:12px;background:rgba(18,18,26,.85)}.report h2{font-size:clamp(1.3rem,3.5vw,1.8rem)}.report h3{font-size:1rem;color:var(--cyan);font-family:var(--f-mono);font-weight:400;text-transform:uppercase;letter-spacing:.08em;margin-top:10px;border-bottom:1px solid var(--line);padding-bottom:6px}.kv{display:flex;flex-wrap:wrap;gap:8px}.kv span{font-family:var(--f-mono);font-size:.75rem;padding:6px 10px;border:1px solid var(--line);border-radius:5px}.kv b{color:var(--white)}.d-list{list-style:none;margin:0;padding:0;display:grid;gap:12px}.d-list li{display:grid;grid-template-columns:70px 1fr;gap:10px}.d-list li>div{display:grid;gap:3px}.d-list b{color:var(--white);font-weight:600}.d-list p{font-size:.88rem}.d-list i{font-style:normal;font-size:.75rem;color:var(--slate)}.d-ses{font-family:var(--f-mono);font-size:.7rem;color:var(--slate)}.d-fl b{color:var(--mag)}.missed{color:var(--warn)}/* dialogs */dialog.sheet{width:min(560px,100%);max-height:92vh;max-height:92dvh;overflow:auto;padding:20px;border:1px solid var(--line-c);border-radius:12px;background:#12121A;color:var(--silver);box-shadow:0 0 40px rgba(0,240,255,.18)}dialog.sheet::backdrop{background:rgba(5,5,10,.72);backdrop-filter:blur(3px);-webkit-backdrop-filter:blur(3px)}@media (max-width:620px){dialog.sheet{margin:auto 0 0;width:100%;max-width:100%;border-radius:14px 14px 0 0;padding-bottom:calc(20px + env(safe-area-inset-bottom))}}dialog.sheet form{display:grid;gap:14px}dialog.sheet label{display:grid;gap:6px}.sheet-head{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px}.sheet-head h2{font-size:1.25rem}.seg.wide{display:flex;width:100%;margin-bottom:16px}.seg.wide button{flex:1;padding:9px}.l-body{display:grid;gap:14px;min-height:120px}.l-foot{font-size:.78rem;margin-top:14px}.qr-wrap{justify-self:center;width:min(330px,82vw);padding:10px;border-radius:10px;background:#fff;box-shadow:0 0 0 1px var(--line-c),0 0 30px rgba(0,240,255,.3)}.qr{display:block;width:100%;height:auto}.steps{margin:0;padding-left:20px;display:grid;gap:6px;font-size:.9rem}.steps b{color:var(--white)}.big-step{text-align:center;font-size:1.05rem;color:var(--white)}.spin{display:flex;align-items:center;gap:10px;color:var(--silver)}.spin::before{content:"";width:16px;height:16px;border-radius:50%;border:2px solid var(--line-c);border-top-color:var(--cyan);animation:spin .8s linear infinite;flex:none}@keyframes spin{to{transform:rotate(360deg)}}.trouble{margin-top:10px;padding:12px 14px;border:1px solid rgba(244,255,90,.35);border-radius:6px;background:rgba(244,255,90,.05);font-size:.85rem}.trouble b{color:var(--white)}.trouble ul{margin:6px 0 0;padding-left:18px;display:grid;gap:4px}.ok-big{display:grid;justify-items:center;gap:8px;text-align:center}.ok-big svg{width:56px;height:56px;fill:none;stroke:var(--ok);stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 0 8px var(--ok))}.ok-big p b{color:var(--white)}.paste-d{font-size:.85rem}.paste-d summary{cursor:pointer;color:var(--cyan)}.paste-d textarea{margin:8px 0;min-height:70px}.l-scan,.demo-pair{display:grid;gap:12px;justify-items:start}.viewfinder{position:relative;border-radius:10px;overflow:hidden;background:#000;aspect-ratio:4/3;margin-bottom:12px}.viewfinder video{width:100%;height:100%;object-fit:cover;display:block}.reticle{position:absolute;inset:14%;border:2px solid rgba(0,240,255,.8);border-radius:12px;box-shadow:0 0 0 999px rgba(0,0,0,.35),0 0 18px rgba(0,240,255,.6)}.cats{display:flex;flex-wrap:wrap;gap:8px}.cats .chip[aria-checked="true"]{background:color-mix(in srgb,var(--cc) 24%,transparent);border-color:var(--cc);color:var(--white);box-shadow:0 0 12px color-mix(in srgb,var(--cc) 45%,transparent)}.toast{position:fixed;left:50%;bottom:96px;transform:translate(-50%,20px);opacity:0;pointer-events:none;z-index:80;width:max-content;max-width:calc(100% - 32px);padding:10px 16px;border:1px solid var(--line-c);border-radius:6px;background:rgba(13,13,18,.96);color:var(--white);font-family:var(--f-mono);font-size:.8rem;transition:opacity .2s,transform .2s;text-align:center}.toast.on{opacity:1;transform:translate(-50%,0)}@media (min-width:900px){.toast{bottom:28px}}@media (max-width:620px){ .app-tag{display:none} .row3{grid-template-columns:1fr 1fr} .row3 label:first-child{grid-column:1/-1} .ses{grid-template-columns:1fr;gap:6px} .ses-t{grid-auto-flow:column;justify-content:start;align-items:center} .ses-t .tm{grid-auto-flow:column;gap:6px} .ses-li{grid-template-columns:auto 1fr auto;grid-template-areas:"tm tt avs" "pr tt avs"} .ses-li .tm{grid-area:tm}.ses-li .pr{grid-area:pr;justify-self:start}.ses-li .tt{grid-area:tt}.ses-li .avs{grid-area:avs} .composer .switch span:last-child{display:none}}@media print{ body{background:#fff;color:#111} body::before,.app-head,.tabs,.fab,.no-print,.demo-bar,.flash,.toast,.app-foot{display:none!important} .cm-main{padding:0;max-width:none} .report{background:#fff;border:0;box-shadow:none;backdrop-filter:none;color:#111;padding:0} .report h2,.report b,.d-list b,.kv b{color:#000;text-shadow:none} .report h3{color:#333;border-color:#ccc} .d-list i,.d-ses,.muted,.d-list p{color:#444} .kv span{border-color:#ccc}}/* grid lists: let long single-line text shrink instead of widening the page */.ses-list>li,.gaps>li,.fl-list>li,.crew-list>li,.d-list>li,.cm-list>li,.view>*,.ctl-grid>*,.card{min-width:0}.fl-li{min-width:0}.j-box{display:grid;gap:14px}.form,.form form,.row2>*,.row3>*,.input,select{min-width:0}input[type=date],input[type=time]{min-width:0;width:100%}dialog textarea,.form textarea{min-height:70px}.relink-tip{margin-top:10px;padding:10px 12px;border:1px solid var(--line-m);border-radius:6px;background:rgba(255,42,133,.06);font-size:.84rem}.relink-tip b{color:var(--white)}.crew-acts{grid-column:3;grid-row:1;display:flex;flex-direction:column;align-items:flex-end;gap:6px}.crew-acts .linkbtn{font-size:.75rem}sw.js29 lines
/* Congress Mission service worker: the whole app works offline, with no network at all. Mission data lives on the device (localStorage) and travels phone to phone; nothing passes through here. Change VERSION on every release. */const VERSION = 'congress-mission-1.1.0';const SHELL = ['./', './index.html', './app.js', './store.js', './mesh.js', './demo.js', './scan-worker.js', './style.css', './mafflab-theme.css', './vendor/qrcode.js', './vendor/jsQR.js', './manifest.webmanifest', './fonts/space-grotesk.woff2', './fonts/inter.woff2', './icons/icon.svg', './icons/icon-192.png', './icons/icon-512.png', './icons/maskable-512.png', './icons/apple-touch-icon.png'];const PREFIX = 'congress-mission-';self.addEventListener('install', e => e.waitUntil(caches.open(VERSION).then(c => c.addAll(SHELL)).then(() => self.skipWaiting())));self.addEventListener('activate', e => e.waitUntil( caches.keys().then(ks => Promise.all(ks.filter(k => k.startsWith(PREFIX) && k !== VERSION).map(k => caches.delete(k)))).then(() => self.clients.claim())));self.addEventListener('fetch', e => { const url = new URL(e.request.url); if (e.request.method !== 'GET' || url.origin !== location.origin) return; if (e.request.mode === 'navigate') { e.respondWith(fetch(e.request).then(r => { const c = r.clone(); caches.open(VERSION).then(x => x.put('./index.html', c)); return r; }).catch(() => caches.match('./index.html'))); return; } e.respondWith(caches.match(e.request, { ignoreSearch: true }).then(hit => hit || fetch(e.request)));});self.addEventListener('notificationclick', e => { e.notification.close(); e.waitUntil(self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => { const c = list.find(x => x.url.includes('/congress-mission/')); return c ? c.focus() : self.clients.openWindow('./'); }));});manifest.webmanifest45 lines
{ "id": "./", "name": "Congress Mission: your team's mission control at a medical congress", "short_name": "Mission", "description": "Plan who covers which sessions, share insights and flash alerts, and leave with the debrief written. Phones link directly over the same WiFi: no server, no accounts.", "start_url": "./", "scope": "./", "display": "standalone", "orientation": "any", "background_color": "#0D0D12", "theme_color": "#0D0D12", "lang": "en", "categories": [ "medical", "productivity", "business" ], "icons": [ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, { "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } ]}tests/store.test.js128 lines
/* Store checks: convergence under random concurrent edits, clock behaviour, removal, imports. node tests/store.test.js */'use strict';const S = require('../store.js');let fails = 0, n = 0;const ok = (c, label, d) => { n++; if (!c) fails++; console.log(`${c ? 'PASS' : 'FAIL'} ${label}${d !== undefined ? ' ' + d : ''}`); };/* deterministic random numbers */let seed = 7;const R = () => (seed = (seed * 1103515245 + 12345) >>> 0) / 4294967296;const pick = a => a[Math.floor(R() * a.length)];/* 1. convergence: 4 devices, skewed clocks, 3,000 random writes, random partial syncs with lost messages */{ let t = 1_790_000_000_000; const skew = [0, 4000, -2500, 900]; const devs = ['dev-aaaaaa', 'dev-bbbbbb', 'dev-cccccc', 'dev-dddddd'].map((me, i) => new S.Store({ mission: 'm', me, now: () => t + skew[i] })); const ids = Array.from({ length: 40 }, (_, i) => 'rec' + i); const kinds = {}; ids.forEach((id, i) => kinds[id] = i % 2 ? 'session' : 'insight'); const fieldsFor = k => k === 'session' ? ['title', 'room', 'prio', 'a.dev-aaaaaa', 'a.dev-bbbbbb'] : ['head', 'body', 'flash', 's.dev-cccccc']; const writes = []; // every write, to compute the expected winner for (let step = 0; step < 3000; step++) { t += Math.floor(R() * 40); const d = pick(devs), id = pick(ids), f = pick(fieldsFor(kinds[id])); const v = f === 'prio' ? Math.floor(R() * 3) + 1 : f.includes('.') || f === 'flash' ? R() < .5 : 'v' + step; const delta = d.set(id, kinds[id], { [f]: v }); if (delta) writes.push(delta.f[f][1] + '|' + id + '|' + f); if (R() < 0.15) { // gossip a delta to a random device, sometimes lost const to = pick(devs); if (delta && to !== d && R() < 0.7) to.merge([delta]); } if (R() < 0.05) { // a pairwise sync by digests, as on reconnection const a = pick(devs), b = pick(devs); if (a !== b) { const da = a.digest(), db = b.digest(); b.merge(a.diff(db)); a.merge(b.diff(da)); } } } // final: everyone syncs with everyone once (a connected mesh) for (const a of devs) for (const b of devs) if (a !== b) { const da = a.digest(), db = b.digest(); b.merge(a.diff(db)); a.merge(b.diff(da)); } const dg = devs.map(d => { const x = d.digest(); return Object.keys(x).sort().map(k => k + ':' + x[k]).join(','); }); ok(dg.every(x => x === dg[0]), 'four devices with skewed clocks converge after random, lossy syncs', `${writes.length} writes`); // the surviving value of each field is the write with the highest clock const best = {}; for (const w of writes) { const [h, id, f] = w.split('|'); const k = id + '|' + f; if (!best[k] || h > best[k]) best[k] = h; } let agree = 0, total = 0; for (const k in best) { const [id, f] = k.split('|'); total++; if (devs[0].get(id).f[f][1] === best[k]) agree++; } ok(agree === total, 'every field holds its latest write (last writer wins)', `${agree}/${total}`); // a diff between converged devices is empty ok(devs[1].diff(devs[0].digest()).length === 0, 'diff between converged devices is empty');}/* 2. a field another device never received is repaired by the digest exchange (not just "newest per record") */{ let t = 1_790_000_000_000; const a = new S.Store({ mission: 'm', me: 'dev-aaaaaa', now: () => t }), b = new S.Store({ mission: 'm', me: 'dev-bbbbbb', now: () => t }); const d1 = a.set('s1', 'session', { title: 'Plenary' }); b.merge([d1]); t += 10; a.set('s1', 'session', { room: 'Hall A' }); // b misses this one t += 10; const d3 = b.set('s1', 'session', { prio: 1 }); // b's write is newer than a's room a.merge([d3]); const da = a.digest(), db = b.digest(); b.merge(a.diff(db)); a.merge(b.diff(da)); ok(b.val('s1', 'room') === 'Hall A' && a.val('s1', 'prio') === 1, 'older field missed by one device is still repaired');}/* 3. clocks */{ let t = 1000; const c = new S.Clock('dev-aaaaaa', () => t); const h1 = c.tick(), h2 = c.tick(); ok(h2 > h1 && S.msOf(h1) === 1000, 'clock strictly increases within the same millisecond'); c.recv(S.encH(5000, 3, 'dev-bbbbbb')); const h3 = c.tick(); ok(h3 > S.encH(5000, 3, 'dev-bbbbbb'), 'clock moves past a peer that is ahead'); c.recv(S.encH(t + 3 * 864e5, 0, 'dev-zzzzzz')); ok(S.msOf(c.tick()) < t + 864e5, 'a peer clock days in the future is ignored');}/* 4. validation and removal */{ let t = 1_790_000_000_000; const a = new S.Store({ mission: 'm', me: 'dev-aaaaaa', now: () => t }); const bad = a.merge([{ id: 'x', k: 'evil', f: { a: [1, S.encH(t, 0, 'dev-bbbbbb')] } }, { id: 'y', k: 'msg', f: { text: ['<b>'.repeat(2000), S.encH(t, 0, 'dev-bbbbbb')] } }, { id: 'z', k: 'msg', f: { text: [{ o: 1 }, S.encH(t, 0, 'dev-bbbbbb')], html: ['x', S.encH(t, 0, 'dev-bbbbbb')] } }]); ok(bad.length === 0 && a.recs.size === 0, 'unknown kinds, oversized text, objects and unknown fields are refused'); a.merge([{ id: 'dev-bbbbbb', k: 'member', f: { name: ['Bea', S.encH(t, 0, 'dev-bbbbbb')] } }]); t += 100; a.set('dev-bbbbbb', 'member', { rm: true }); t += 100; const after = a.merge([{ id: 'm1', k: 'msg', f: { text: ['still here', S.encH(t, 0, 'dev-bbbbbb')] } }, { id: 'dev-bbbbbb', k: 'member', f: { rm: [false, S.encH(t, 1, 'dev-bbbbbb')] } }]); ok(after.length === 0 && a.removed('dev-bbbbbb'), 'a removed member cannot write or restore itself'); const before = a.merge([{ id: 'm0', k: 'msg', f: { text: ['sent before removal', S.encH(t - 150, 0, 'dev-bbbbbb')] } }]); ok(before.length === 1, 'messages a member wrote before removal still arrive');}/* 5. persistence */{ const mem = { d: {}, getItem(k) { return this.d[k] || null; }, setItem(k, v) { this.d[k] = v; }, removeItem(k) { delete this.d[k]; } }; const a = new S.Store({ mission: 'mm', me: 'dev-aaaaaa', storage: mem }); a.set('mission', 'mission', { name: 'ESMO 2026' }); a.save(true); const b = new S.Store({ mission: 'mm', me: 'dev-aaaaaa', storage: mem }); ok(b.val('mission', 'name') === 'ESMO 2026' && b.clock.tick() > a.get('mission').f.name[1], 'store reloads from storage and its clock continues past saved writes');}/* 6. ICS import */{ const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'BEGIN:VEVENT', 'SUMMARY:LBA 3: Phase 3 results of ex', ' ample\\, with a comma', 'DTSTART;TZID=Europe/Berlin:20261020T083000', 'DTEND;TZID=Europe/Berlin:20261020T100000', 'LOCATION:Hall A\\; level 2', 'END:VEVENT', 'BEGIN:VEVENT', 'SUMMARY:Poster walk', 'DTSTART:20261020T120000Z', 'DTEND:20261020T130000Z', 'END:VEVENT', 'BEGIN:VEVENT', 'SUMMARY:Congress day', 'DTSTART;VALUE=DATE:20261020', 'END:VEVENT', 'END:VCALENDAR'].join('\r\n'); const ev = S.parseICS(ics); const berlin = new Date(Date.UTC(2026, 9, 20, 6, 30)); // 08:30 in Berlin (CEST, UTC+2) is 06:30 UTC ok(ev.length === 2 && ev[0].title === 'LBA 3: Phase 3 results of example, with a comma' && ev[0].room === 'Hall A; level 2', 'ICS: folded lines, escapes, all-day events skipped'); ok(ev[0].start === S.hm(berlin) && ev[0].day === S.ymd(berlin), 'ICS: TZID times converted to device time', `${ev[0].day} ${ev[0].start} (device TZ ${process.env.TZ || 'default'})`); ok(ev[1].start === S.hm(new Date(Date.UTC(2026, 9, 20, 12, 0))), 'ICS: UTC times converted to device time'); ok(S.tzOffset('Europe/Madrid', Date.UTC(2026, 6, 1)) === 120 && S.tzOffset('Europe/Madrid', Date.UTC(2026, 11, 1)) === 60, 'time zone offsets from Intl (summer and winter)');}/* 7. CSV import */{ const csv = 'Title;Date;Start;End;Room;Priority;Topic\n"Late-breaking; session 2";20/10/2026;8.30;10:00;Hall A;1;Competitor\nPosters;2026-10-21;12:00;;Hall C;;Own\nbroken row;;;;\n'; const rows = S.parseCSV(csv); ok(rows.length === 2 && rows[0].title === 'Late-breaking; session 2' && rows[0].day === '2026-10-20' && rows[0].start === '08:30' && rows[0].prio === 1, 'CSV: semicolons, quotes, European dates, times with dots'); ok(rows[1].end === '13:00' && rows[1].prio === 2 && rows[1].tag === 'Own', 'CSV: missing end and priority get defaults');}console.log(`\n${n - fails}/${n} passed`);process.exit(fails ? 1 : 0);tests/signal.test.js56 lines
/* Pairing-code checks: compact SDP round trips for the SDP styles of Chrome, Firefox and Safari. node tests/signal.test.js chrome-offer.sdp was produced by Chromium 153; the Firefox and Safari files follow those browsers' SDP layout (session-level fingerprint and upper-case UDP in Firefox; srflx and TCP candidates). */'use strict';const fs = require('fs'), path = require('path');const M = require('../mesh.js');let fails = 0, n = 0;const ok = (c, label, d) => { n++; if (!c) fails++; console.log(`${c ? 'PASS' : 'FAIL'} ${label}${d !== undefined ? ' ' + d : ''}`); };const fx = f => fs.readFileSync(path.join(__dirname, 'fixtures', f), 'utf8');const meta = { mission: 'Kp3mN8x2Qa7z', mname: 'ESMO 2026 · Berlin', member: 'Ab12Cd34Ef56', name: 'Laura Martínez', nonce: 4000000001 };(async () => { for (const [file, type] of [['chrome-offer.sdp', 'offer'], ['firefox-offer.sdp', 'offer'], ['safari-answer.sdp', 'answer']]) { const sdp = fx(file), p = M.parseSdp(sdp); ok(!!p, `${file}: parsed`, p && `${p.cands.length} UDP candidate(s), setup ${p.setup}`); const code = await M.encodeSignal(type, sdp, meta); const d = await M.decodeSignal(code); const q = M.parseSdp(d.sdp); const same = q && q.ufrag === p.ufrag && q.pwd === p.pwd && q.setup === p.setup && q.mid === p.mid && Buffer.from(q.fp).equals(Buffer.from(p.fp)) && JSON.stringify(q.cands) === JSON.stringify(p.cands); ok(same && d.type === type && JSON.stringify(d.meta) === JSON.stringify(meta), `${file}: credentials, fingerprint, candidates and meta survive the code`, `${code.length} characters`); ok(!/tcp/i.test(d.sdp.split('\n').filter(l => l.startsWith('a=candidate')).join('\n')), `${file}: TCP candidates dropped`); } // Firefox's IPv6 and srflx candidates come back in canonical form const fd = await M.decodeSignal(await M.encodeSignal('offer', fx('firefox-offer.sdp'), meta)); ok(/ 2a01:4f8:c17:b8f::2 51473 typ host/.test(fd.sdp) && / 83\.45\.120\.9 51472 typ srflx raddr 0\.0\.0\.0 rport 0/.test(fd.sdp), 'IPv6 and srflx candidates rebuilt'); // IPv6 helpers const cases = ['::1', 'fe80::1', '2001:db8::', '2001:db8:0:0:1:0:0:1', '2a01:4f8:c17:b8f::2', '1:2:3:4:5:6:7:8']; ok(cases.every(c => M.ip6str(M.ip6(c)) === (c === '2001:db8:0:0:1:0:0:1' ? '2001:db8::1:0:0:1' : c)), 'IPv6 to bytes and back (canonical)'); ok(M.ip6('fe80::1%en0') === null && M.ip6('1::2::3') === null, 'odd IPv6 forms fall back to text'); // SDP the compact form cannot hold (audio + data) still pairs, through the compressed form const multi = fx('chrome-offer.sdp').replace('m=application', 'm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=mid:1\r\nm=application'); const zc = await M.encodeSignal('offer', multi, meta); const zd = await M.decodeSignal(zc); ok(zc[0] === 'A' && zd.sdp === multi && zd.meta.name === meta.name, 'unusual SDP is carried whole, compressed', `${zc.length} characters`); // a full invite link decodes, junk does not const link = 'https://mafflab.xyz/apps/congress-mission/#j=' + (await M.encodeSignal('offer', fx('chrome-offer.sdp'), meta)); ok((await M.decodeSignal(link)).meta.mission === meta.mission, 'invite link (camera app scan) decodes'); let msg = ''; try { await M.decodeSignal('hello there'); } catch (e) { msg = e.message; } ok(/not a Congress Mission code/.test(msg), 'junk is refused with a readable message', msg); try { await M.decodeSignal('AQ' + 'A'.repeat(30)); msg = ''; } catch (e) { msg = e.message; } ok(/incomplete|not a Congress/.test(msg), 'truncated code is refused', msg); // the rebuilt SDP is minimal and standard const rb = M.buildSdp(M.parseSdp(fx('firefox-offer.sdp'))); ok(/^v=0\r\n/.test(rb) && rb.includes('a=group:BUNDLE 0') && rb.includes('a=sctp-port:5000') && !rb.includes('undefined'), 'rebuilt SDP is well formed'); console.log(`\n${n - fails}/${n} passed`); process.exit(fails ? 1 : 0);})();tests/e2e.js148 lines
/* End to end: three phones (three separate Chromium browsers, each with its own storage and its own camera) pair by scanning each other's QR codes, form a mesh, sync, alert, and catch up after one of them goes away. The camera is Chromium's fake capture device playing a video of the other phone's screen, made with ffmpeg from a screenshot of the code. node tests/e2e.js (needs Playwright with Chromium, and ffmpeg) Set MDNS=0 to switch off Chromium's mDNS address hiding if the machine has no multicast. */'use strict';const { chromium } = require('playwright');const http = require('http'), fs = require('fs'), path = require('path'), os = require('os'), { execFileSync } = require('child_process');const ROOT = path.join(__dirname, '..');const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-e2e-'));const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.webmanifest': 'application/manifest+json' };const server = http.createServer((q, r) => { const rel = decodeURIComponent(q.url.split('?')[0].split('#')[0]).replace(/^\/+/, '') || 'index.html'; const f = path.join(ROOT, rel.endsWith('/') ? rel + 'index.html' : rel); if (!f.startsWith(ROOT) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { r.writeHead(404); return r.end(); } r.writeHead(200, { 'Content-Type': TYPES[path.extname(f)] || 'application/octet-stream' }); fs.createReadStream(f).pipe(r);});let fails = 0, n = 0;const ok = (c, label, d) => { n++; if (!c) fails++; console.log(`${c ? 'PASS' : 'FAIL'} ${label}${d !== undefined ? ' ' + d : ''}`); };const sleep = ms => new Promise(r => setTimeout(r, ms));async function until(fn, ms, step) { const t = Date.now(); while (Date.now() - t < ms) { try { if (await fn()) return true; } catch (e) {} await sleep(step || 250); } return false; }/* a "phone": its own browser process, so it has its own camera file */async function phone(name, url) { const cam = path.join(TMP, name + '.y4m'); fs.writeFileSync(cam, ''); const args = ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--use-file-for-fake-video-capture=' + cam]; if (process.env.MDNS === '0') args.push('--disable-features=WebRtcHideLocalIpsWithMdns'); const browser = await chromium.launch({ args }); const ctx = await browser.newContext({ viewport: { width: 420, height: 880 }, permissions: ['camera'] }); const page = await ctx.newPage(); const errs = []; page.on('pageerror', e => errs.push(e.message)); page.on('console', m => { if (m.type() === 'error') errs.push(m.text()); }); await page.goto(url, { waitUntil: 'networkidle' }); return { name, browser, ctx, page, cam, errs };}/* point a phone's camera at a QR code on another phone's screen */async function aim(from, to) { const el = await from.page.waitForSelector('.qr-wrap svg', { timeout: 15000 }); const png = path.join(TMP, from.name + '-qr.png'); await el.screenshot({ path: png }); execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-loop', '1', '-i', png, '-vf', 'scale=560:-2,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x202020,format=yuv420p', '-t', '2', '-r', '8', to.cam]);}const reach = p => p.page.evaluate(() => { const r = CMApp.A.mesh.reach(); return { direct: [...r.direct], via: [...r.via.keys()] }; });const meId = p => p.page.evaluate(() => CMApp.A.me);const val = (p, fn) => p.page.evaluate(fn);const dump = p => p.page.evaluate(() => ({ me: CMApp.A.me, links: [...CMApp.A.mesh.links].map(l => ({ peer: l.peer, open: l.open, via: l.via, roster: l.roster, hello: !!l.hello, st: l.pc.connectionState })), dialing: [...CMApp.A.mesh.dialing.keys()], tries: [...CMApp.A.mesh.tries.entries()] })).catch(e => e.message);const click = (p, sel) => p.page.evaluate(s => document.querySelector(s).click(), sel);(async () => { await new Promise(r => server.listen(0, '127.0.0.1', r)); const URL = `http://127.0.0.1:${server.address().port}/`; const A = await phone('laura', URL), B = await phone('javier', URL), C = await phone('priya', URL); try { /* Laura starts the mission */ await click(A, '#w-new'); await A.page.fill('#n-name', 'ESMO 2026'); await A.page.fill('#n-me', 'Laura'); await A.page.fill('#n-role', 'Medical Lead'); await click(A, '#f-new button[type=submit]'); await A.page.waitForSelector('#v-app:not([hidden])'); await A.page.evaluate(() => { const s = CMApp.A.store; s.set('ses01', 'session', { title: 'LBA 1', day: new Date().toISOString().slice(0, 10), start: '09:00', end: '10:00', room: 'Hall A', prio: 1, tag: 'comp' }); }); /* Laura shows her code; Javier scans it with his camera */ await click(A, '[data-act="link"]'); await aim(A, B); ok(await val(A, () => document.querySelector('.qr-wrap svg').viewBox.baseVal.width) <= 77, 'invite QR is small enough for a phone camera', (await val(A, () => document.querySelector('.qr-wrap svg').viewBox.baseVal.width - 8)) + ' modules'); await click(B, '#w-join'); await click(B, '#j-scan'); ok(await until(() => B.page.isVisible('#f-join'), 20000), 'Javier\'s camera reads Laura\'s invite (jsQR in a worker)'); ok((await B.page.textContent('#j-info')).includes('ESMO 2026'), 'invite shows the mission name and who sent it', (await B.page.textContent('#j-info')).replace(/\s+/g, ' ')); await B.page.fill('#j-me', 'Javier'); await click(B, '#f-join button[type=submit]'); await B.page.waitForSelector('#j-msg .qr-wrap svg', { timeout: 15000 }); /* Laura scans Javier's reply */ await aim(B, A); await click(A, '#l-reply'); ok(await until(async () => (await reach(A)).direct.length === 1, 25000), 'Laura and Javier link directly after two scans'); const shown = [await until(() => A.page.isVisible('.ok-big'), 5000), await until(() => B.page.isVisible('#j-msg .ok-big'), 5000)]; ok(shown[0] && shown[1], 'both phones show "Linked"', shown.join(' ')); if (!shown[0] || !shown[1]) for (const p of [A, B]) console.log(' ' + p.name, JSON.stringify(await dump(p)), (await p.page.textContent('#l-body').catch(() => '')).slice(0, 80), (await p.page.textContent('#j-msg').catch(() => '')).slice(0, 80)); ok(await until(() => val(B, () => CMApp.A.store.val('mission', 'name', '') === 'ESMO 2026' && !!CMApp.A.store.get('ses01')), 8000), 'Javier receives the mission and the plan'); await B.page.evaluate(() => document.querySelector('#j-msg [data-done]').click()); await A.page.evaluate(() => document.querySelector('#d-link [data-done]').click()); /* Priya joins through Javier; she and Laura then link by themselves through the mesh */ await click(B, '[data-act="link"]'); await aim(B, C); await click(C, '#w-join'); await click(C, '#j-scan'); ok(await until(() => C.page.isVisible('#f-join'), 20000), 'Priya reads Javier\'s invite'); await C.page.fill('#j-me', 'Priya'); await click(C, '#f-join button[type=submit]'); await C.page.waitForSelector('#j-msg .qr-wrap svg', { timeout: 15000 }); await aim(C, B); await click(B, '#l-reply'); ok(await until(async () => (await reach(C)).direct.length >= 1, 25000), 'Priya and Javier link'); const aId = await meId(A), cId = await meId(C); const t0 = Date.now(); const meshOK = await until(async () => (await reach(A)).direct.includes(cId) && (await reach(C)).direct.includes(aId), 30000); ok(meshOK, 'Laura and Priya link directly through the mesh, without scanning', ((Date.now() - t0) / 1000).toFixed(1) + ' s'); if (!meshOK || process.env.DEBUG) for (const p of [A, B, C]) console.log(' ' + p.name, JSON.stringify(await dump(p))); await C.page.evaluate(() => document.querySelector('#j-msg [data-done]').click()); /* a flash from Priya buzzes Laura and Javier */ await C.page.evaluate(() => CMApp.A.store.set('flash1', 'insight', { cat: 'comp', head: 'Competitor OS HR 0.78 at interim', sowhat: 'Brief launch team', ses: 'ses01', flash: true, at: Date.now(), by: CMApp.A.me })); ok(await until(() => A.page.isVisible('#flash'), 8000) && await until(() => B.page.isVisible('#flash'), 8000), 'a flash shows an alert on every linked phone'); ok((await A.page.textContent('#flash')).includes('Priya'), 'the alert says who sent it', (await A.page.textContent('#flash')).replace(/\s+/g, ' ').slice(0, 70)); /* Javier's phone goes away; Laura and Priya keep talking; Javier catches up when he relinks */ const bId = await meId(B); await B.page.close(); const tg = Date.now(); ok(await until(async () => { const r = await reach(A); return !r.direct.includes(bId) && r.direct.includes(cId); }, 45000, 500), 'Laura notices Javier is gone and stays linked to Priya', ((Date.now() - tg) / 1000).toFixed(1) + ' s'); await A.page.evaluate(() => CMApp.A.store.set('m-away', 'msg', { text: 'Javier, the satellite moved to Hall C', pri: '', at: Date.now(), by: CMApp.A.me })); ok(await until(() => val(C, () => !!CMApp.A.store.get('m-away')), 8000), 'Priya gets Laura\'s message while Javier is away'); B.page = await B.ctx.newPage(); await B.page.goto(URL, { waitUntil: 'networkidle' }); ok(await val(B, () => CMApp.A.store && CMApp.A.store.val('mission', 'name', '')) === 'ESMO 2026' && !(await val(B, () => !!CMApp.A.store.get('m-away'))), 'Javier reopens the app: his copy is intact, the new message has not arrived yet'); // relink: Javier scans Laura's code from inside the app (he already belongs to the mission) ok(await until(() => A.page.isVisible('[data-act="relink"]'), 5000) || (await reach(A)).direct.length === 1, 'Laura\'s Control offers Relink while Javier is away'); await click(A, '[data-act="relink"],[data-act="link"]'); await aim(A, B); await click(B, '[data-act="relink"],[data-act="link"]'); await B.page.evaluate(() => document.querySelector('#l-seg [data-lmode="scan"]').click()); await click(B, '#l-scan-go'); await B.page.waitForSelector('#l-body .qr-wrap svg', { timeout: 20000 }); await aim(B, A); await click(A, '#l-reply'); ok(await until(() => val(B, () => !!CMApp.A.store.get('m-away')), 25000), 'after relinking, Javier catches up on what he missed'); ok(await until(async () => (await reach(B)).direct.length === 2, 30000), 'and links to Priya again by himself'); /* everyone holds the same data */ await sleep(1500); const dg = await Promise.all([A, B, C].map(p => p.page.evaluate(() => { const d = CMApp.A.store.digest(); return Object.keys(d).sort().map(k => k + d[k]).join(); }))); ok(dg[0] === dg[1] && dg[1] === dg[2], 'all three phones hold identical mission data', dg[0].split(',').length + ' records'); const errs = [...A.errs, ...B.errs, ...C.errs].filter(e => !/favicon|404/.test(e)); ok(!errs.length, 'no script errors', errs.slice(0, 3).join(' | ')); } catch (e) { fails++; console.log('FAIL ' + e.message.split('\n')[0]); for (const p of [A, B, C]) try { await p.page.screenshot({ path: path.join(TMP, p.name + '-fail.png') }); } catch (x) {} console.log('screenshots in ' + TMP); } finally { for (const p of [A, B, C]) await p.browser.close().catch(() => {}); server.close(); } console.log(`\n${n - fails}/${n} passed`); process.exit(fails ? 1 : 0);})();.htaccess14 lines
# Congress Mission: security headers for Apache and LiteSpeed (Hostinger, IONOS and most shared hosting).# The camera is allowed for this folder only, to scan teammates' pairing codes. Images never leave the device.DirectoryIndex index.htmlAddType application/manifest+json .webmanifest<IfModule mod_headers.c> Header always set Referrer-Policy "no-referrer" Header always set X-Content-Type-Options "nosniff" Header always set X-Frame-Options "SAMEORIGIN" Header always set Permissions-Policy "camera=(self), microphone=(), geolocation=(), browsing-topics=()" <FilesMatch "^(sw\.js|index\.html|manifest\.webmanifest)$"> Header set Cache-Control "no-cache" </FilesMatch></IfModule>vendor/README.md11 lines
# Third-party codeBoth libraries are unmodified copies from npm.| File | Library | Version | Licence ||---|---|---|---|| `qrcode.js` | [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) by Kazuhiko Arase | 2.0.4 | MIT (notice at the top of the file) || `jsQR.js` | [jsQR](https://github.com/cozmo/jsQR) by Cosmo Wolfe | 1.4.0 | Apache 2.0 (`jsQR-LICENSE.txt`) |`qrcode.js` draws the pairing codes. `jsQR.js` reads them from the camera in `scan-worker.js`, on browsers without a built-in barcode reader. "QR Code" is a registered trademark of DENSO WAVE INCORPORATED.tests/relink.e2e.js113 lines
/* Relinking after the WiFi goes away. Two phones pair and talk; then every packet between them is dropped (iptables) while both apps keep running, as when a phone leaves the WiFi; then the network comes back. Checks that both phones notice, that a short blip heals by itself, and that after a long drop they relink by scanning codes again, several times in a row, and catch up. sudo node tests/relink.e2e.js (needs root for iptables, Playwright with Chromium, ffmpeg) */'use strict';const H = require('./phones.js');const { ok, until, sleep, val, reach, meId, dump, network, pair, closeSheets } = H;const RELINK = process.env.REPRO ? '[data-act="link"]' : '[data-act="relink"]'; // REPRO=1: the version without a Relink button(async () => { const { server, url } = await H.serve(); const A = await H.phone('laura', url), B = await H.phone('javier', url); const say = (p, text) => p.page.evaluate(t => CMApp.A.store.set('m' + Math.random().toString(36).slice(2, 10), 'msg', { text: t, pri: '', at: Date.now(), by: CMApp.A.me }), text); const has = (p, text) => p.page.evaluate(t => CMApp.A.store.all('msg').some(m => m.text === t), text); const linked = async () => (await reach(A)).direct.length === 1 && (await reach(B)).direct.length === 1; const unlinked = async () => (await reach(A)).direct.length === 0 && (await reach(B)).direct.length === 0; try { await H.click(A, '#w-new'); await A.page.fill('#n-name', 'ESMO 2026'); await A.page.fill('#n-me', 'Laura'); await H.click(A, '#f-new button[type=submit]'); await A.page.waitForSelector('#v-app:not([hidden])'); await pair(A, B, { newMember: true, name: 'Javier' }); ok(await until(linked, 25000), 'Laura and Javier pair'); await closeSheets(A, B); await say(A, 'hello 1'); ok(await until(() => has(B, 'hello 1'), 6000), 'messages flow'); /* a short blip: the link should survive or heal without scanning */ network(false); await sleep(5000); network(true); await say(B, 'after blip'); ok(await until(() => has(A, 'after blip'), 15000), 'a 5-second network blip heals by itself'); for (let round = 1; round <= 3; round++) { /* the WiFi goes away for a while, both apps keep running */ network(false); const t0 = Date.now(); const noticed = await until(unlinked, 40000, 500); ok(noticed, `round ${round}: both phones show the teammate as not linked`, ((Date.now() - t0) / 1000).toFixed(0) + ' s'); const gone = p => p.page.evaluate(() => !CMApp.A.mesh.openLinks().length); const t2 = Date.now(); ok(await until(async () => await gone(A) && await gone(B), 40000, 500), `round ${round}: both phones drop the dead link`, ((Date.now() - t2) / 1000 + (t2 - t0) / 1000).toFixed(0) + ' s after the drop'); if (!process.env.REPRO) ok(await until(() => A.page.isVisible(RELINK), 3000), `round ${round}: Control offers Relink`); await say(A, 'while away ' + round); // written while unlinked network(true); await sleep(1500); /* relink by codes: alternate who shows the code */ const [inv, scn] = round % 2 ? [A, B] : [B, A]; await pair(inv, scn, { from: RELINK }); if (!process.env.REPRO) {} const t1 = Date.now(); const back = await until(linked, 25000); ok(back, `round ${round}: they relink by scanning (${inv.name} shows the code)`, ((Date.now() - t1) / 1000).toFixed(1) + ' s'); if (!back) { console.log(' A', JSON.stringify(await dump(A))); console.log(' B', JSON.stringify(await dump(B))); } ok(await until(() => has(B, 'while away ' + round), 8000), `round ${round}: the message written while apart arrives`); await sleep(2500); // past the duplicate-link settling time ok(await linked(), `round ${round}: the new link stays up`); await say(B, 'live ' + round); ok(await until(() => has(A, 'live ' + round), 6000), `round ${round}: live messages flow on the new link`); await closeSheets(A, B); } /* a 9-second drop: past the point where both phones mark the link as down, but it heals by itself */ network(false); await sleep(9000); network(true); await say(B, 'after nine'); ok(await until(async () => await linked() && await has(A, 'after nine'), 15000), 'a 9-second drop heals by itself, without scanning'); /* the case that used to fail: the user relinks straight away while both phones still hold the old link, and that old link is dead (as when a phone comes back to the WiFi with a new address). The old link is made a zombie: it looks open but carries nothing either way. */ const zombie = p => p.page.evaluate(() => { const M = CMApp.A.mesh, L = M.openLinks()[0]; L.zombie = true; L.dc.send = () => {}; if (!M._r) { M._r = M.receive.bind(M); M.receive = (l, raw) => (l.zombie ? undefined : M._r(l, raw)); } }); for (let round = 1; round <= 3; round++) { await zombie(A); await zombie(B); await say(B, 'quick away ' + round); const [inv, scn] = round % 2 ? [B, A] : [A, B]; const any = '[data-act="relink"],[data-act="link"]'; await pair(inv, scn, { from: any }); const t1 = Date.now(); const okNow = await until(async () => await has(A, 'quick away ' + round), 25000); ok(okNow, `quick round ${round}: relinking at once, with the dead old link still held, works`, ((Date.now() - t1) / 1000).toFixed(1) + ' s'); const fake = await val(scn, () => !!document.querySelector('#l-body .ok-big') && !document.querySelector('#l-body .qr-wrap')); ok(!fake || okNow, `quick round ${round}: no false "Linked" before the reply code`); await sleep(16000); // long enough for a dead link to be dropped await say(A, 'quick live ' + round); const stays = await until(() => has(B, 'quick live ' + round), 8000) && await linked(); ok(stays, `quick round ${round}: the new link is the one that stays`); const left = await Promise.all([A, B].map(p => p.page.evaluate(() => CMApp.A.mesh.openLinks().filter(l => l.zombie).length))); ok(left[0] === 0 && left[1] === 0, `quick round ${round}: the old link is gone on both phones`); if (!okNow || !stays) { console.log(' A', JSON.stringify(await dump(A))); console.log(' B', JSON.stringify(await dump(B))); } await closeSheets(A, B); } const dg = await Promise.all([A, B].map(p => p.page.evaluate(() => { const d = CMApp.A.store.digest(); return Object.keys(d).sort().map(k => k + d[k]).join(); }))); ok(dg[0] === dg[1], 'both phones hold identical data', dg[0].split(',').length + ' records'); const errs = [...A.errs, ...B.errs].filter(e => !/favicon|404/.test(e)); ok(!errs.length, 'no script errors', errs.slice(0, 3).join(' | ')); } catch (e) { ok(false, e.message.split('\n')[0]); for (const p of [A, B]) { try { console.log(' ' + p.name, JSON.stringify(await dump(p))); await p.page.screenshot({ path: H.TMP + '/' + p.name + '-fail.png' }); } catch (x) {} } console.log(' screenshots in ' + H.TMP); } finally { try { network(true); } catch (e) {} for (const p of [A, B]) await p.browser.close().catch(() => {}); server.close(); } const r = H.result(); console.log(`\n${r.n - r.fails}/${r.n} passed`); process.exit(r.fails ? 1 : 0);})();tests/phones.js92 lines
/* Test helpers: simulated phones for the end-to-end tests. Each phone is its own Chromium process, with its own storage and its own fake camera, which plays a video of another phone's QR code (made with ffmpeg from a screenshot). Needs Playwright with Chromium, and ffmpeg. */'use strict';const { chromium } = require('playwright');const http = require('http'), fs = require('fs'), path = require('path'), os = require('os'), { execFileSync } = require('child_process');const ROOT = path.join(__dirname, '..');const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-e2e-'));const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.webmanifest': 'application/manifest+json' };function serve() { const server = http.createServer((q, r) => { const rel = decodeURIComponent(q.url.split('?')[0].split('#')[0]).replace(/^\/+/, '') || 'index.html'; const f = path.join(ROOT, rel.endsWith('/') ? rel + 'index.html' : rel); if (!f.startsWith(ROOT) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { r.writeHead(404); return r.end(); } r.writeHead(200, { 'Content-Type': TYPES[path.extname(f)] || 'application/octet-stream' }); fs.createReadStream(f).pipe(r); }); return new Promise(res => server.listen(0, '127.0.0.1', () => res({ server, url: `http://127.0.0.1:${server.address().port}/` })));}let fails = 0, n = 0;const ok = (c, label, d) => { n++; if (!c) fails++; console.log(`${c ? 'PASS' : 'FAIL'} ${label}${d !== undefined ? ' ' + d : ''}`); return c; };const result = () => ({ fails, n });const sleep = ms => new Promise(r => setTimeout(r, ms));async function until(fn, ms, step) { const t = Date.now(); while (Date.now() - t < ms) { try { if (await fn()) return true; } catch (e) {} await sleep(step || 250); } return false; }async function phone(name, url) { const cam = path.join(TMP, name + '.y4m'); fs.writeFileSync(cam, ''); const args = ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--use-file-for-fake-video-capture=' + cam]; if (process.env.MDNS === '0') args.push('--disable-features=WebRtcHideLocalIpsWithMdns'); const browser = await chromium.launch({ args }); const ctx = await browser.newContext({ viewport: { width: 420, height: 880 }, permissions: ['camera'] }); const p = { name, browser, ctx, cam, errs: [] }; p.open = async () => { p.page = await ctx.newPage(); p.page.on('pageerror', e => p.errs.push(e.message)); p.page.on('console', m => { if (m.type() === 'error') p.errs.push(m.text()); }); await p.page.goto(url, { waitUntil: 'networkidle' }); }; await p.open(); return p;}/* point a phone's camera at the QR code on another phone's screen */async function aim(from, to, sel) { const el = await from.page.waitForSelector(sel || '.qr-wrap svg', { timeout: 15000 }); const png = path.join(TMP, from.name + '-qr.png'); await el.screenshot({ path: png }); execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-loop', '1', '-i', png, '-vf', 'scale=560:-2,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x202020,format=yuv420p', '-t', '2', '-r', '8', to.cam]);}const reach = p => p.page.evaluate(() => { const r = CMApp.A.mesh.reach(); return { direct: [...r.direct], via: [...r.via.keys()] }; });const meId = p => p.page.evaluate(() => CMApp.A.me);const val = (p, fn, arg) => p.page.evaluate(fn, arg);const click = (p, sel) => p.page.evaluate(s => { const e = document.querySelector(s); if (!e) throw new Error('no ' + s); e.click(); }, sel);const dump = p => p.page.evaluate(() => ({ me: CMApp.A.me, links: [...CMApp.A.mesh.links].map(l => ({ peer: l.peer, open: l.open, via: l.via, hello: !!l.hello, st: l.pc.connectionState, quiet: Math.round((Date.now() - l.last) / 1000) })), dialing: [...CMApp.A.mesh.dialing.keys()] })).catch(e => e.message);/* the network between the phones: drop every UDP packet except DNS (a WiFi that has gone away) */function network(up) { const rule = ['-p', 'udp', '!', '--dport', '53', '-j', 'DROP']; for (const chain of ['INPUT', 'OUTPUT']) { try { execFileSync('iptables', [up ? '-D' : '-I', chain, ...rule], { stdio: 'ignore' }); } catch (e) { if (!up) throw new Error('iptables is needed to cut the network (run as root)'); } }}/* pair two phones: `inviter` shows its code, `joiner` scans it. A joiner that is not yet in the mission joins from the welcome screen; one already in it scans from Link, Scan their code. */async function pair(inviter, joiner, opts) { opts = opts || {}; await click(inviter, opts.from || '[data-act="link"]'); await aim(inviter, joiner); if (opts.newMember) { await click(joiner, '#w-join'); await click(joiner, '#j-scan'); if (!await until(() => joiner.page.isVisible('#f-join'), 20000)) throw new Error(joiner.name + ' could not read the invite'); await joiner.page.fill('#j-me', opts.name || joiner.name); await click(joiner, '#f-join button[type=submit]'); await joiner.page.waitForSelector('#j-msg .qr-wrap svg', { timeout: 15000 }); await aim(joiner, inviter, '#j-msg .qr-wrap svg'); } else { await click(joiner, opts.from || '[data-act="link"]'); await joiner.page.evaluate(() => document.querySelector('#l-seg [data-lmode="scan"]').click()); await click(joiner, '#l-scan-go'); await joiner.page.waitForSelector('#l-body .qr-wrap svg', { timeout: 20000 }); await aim(joiner, inviter, '#l-body .qr-wrap svg'); } await click(inviter, '#l-reply');}async function closeSheets(...ps) { for (const p of ps) await p.page.evaluate(() => { const b = document.querySelector('#d-link [data-done], #j-msg [data-done]'); if (b) b.click(); const d = document.querySelector('#d-link'); if (d.open) d.close(); });}module.exports = { serve, phone, aim, reach, meId, val, click, dump, network, pair, closeSheets, ok, result, sleep, until, TMP };LICENSE22 lines
MIT LicenseCopyright (c) 2026 Aitor Martínez RosellPermission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.