⚠ This tool generates AI-assisted training scenarios for educational purposes only. Output should be reviewed by a qualified instructor before use. It does not replace formal FRS training, service policy, or SFJ Awards-accredited assessment processes.
Tabletop scenario generator

Incident command
assessment scenarios

AI-generated Level 1 and Level 2 tabletop assessment packs grounded in NFCC NOG, JESIP principles, and SFJ Awards learning outcomes.

Scenarios are generated from national guidance — we can localise them and include scenario information for your Fire Service. Get in touch →

L1 & L2 levels NFCC NOG grounded 6 sections generated 10 / day free
Always review against local policy before use in formal assessment.
Privacy Notice © 2026 CommandGridTools.com
Specific hazards to include in the scenario
Any specific details that must be included in the scenario

Building your scenario

This takes 2–3 minutes — Claude generates all 6 text sections, then builds images, site map, floor plan, SSRI, and VR info automatically.

Awaiting response...

Scenario library

Browse scenarios saved by all users. Rate, comment, and download as PDF.

Scenarios here are shared by users — we can localise and include scenario information for your Fire Service. Get in touch →

', ].join(''); const blob = new Blob([pageHtml], { type: 'text/html' }); const url = URL.createObjectURL(blob); const win = window.open(url, '_blank'); if (win) setTimeout(() => { win.print(); URL.revokeObjectURL(url); }, 500); else URL.revokeObjectURL(url); } function escHtml(s) { return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ── Section H: Artifacts placeholder ────────────────────────────── function renderArtifacts() { const wrapper = mk('div'); ap(wrapper, mk('h2', { text: 'Section H \u2014 Artefacts' })); const box = mk('div', { style: 'text-align:center;padding:3rem 1rem;' }); const icon = mk('div', { text: '\uD83D\uDD12', style: 'font-size:3rem;margin-bottom:1rem;' }); const hd = mk('h3', { text: 'For subscribers only', style: 'color:var(--cyan);font-size:1.3rem;margin-bottom:0.75rem;font-weight:700;' }); const sub = mk('p', { style: 'color:var(--muted);font-size:0.9rem;max-width:420px;margin:0 auto 1.5rem;' }); sub.textContent = 'Scenario artefacts \u2014 supporting props, briefing cards, and resource packs \u2014 will be available to subscribers. Sign up today to get access when this feature launches.'; const btn = mk('button', { cls: 'btn btn-primary', text: 'Sign up today', style: 'margin:0 auto;' }); btn.onclick = () => window.open('https://commandgridtools.com', '_blank'); ap(box, icon, hd, sub, btn); wrapper.appendChild(box); return wrapper; } // ── Section I: Scenario Image ───────────────────────────────────── function renderImages() { const wrapper = mk('div'); ap(wrapper, mk('h2', { text: 'Section I \u2014 Scenario Images' })); if (!currentScenario.id) { ap(wrapper, mk('p', { cls: 'img-status', text: 'Images are generated automatically during scenario creation.' })); return wrapper; } const PHASES = [ { n: 1, label: '1 \u2014 On arrival' }, { n: 2, label: '2 \u2014 T+10 minutes' }, { n: 3, label: '3 \u2014 T+20 minutes' }, { n: 4, label: '4 \u2014 End state' }, ]; const grid = mk('div', { cls: 'img-grid' }); const baseUrl = 'api/scenarios/image/' + currentScenario.id + '/'; PHASES.forEach(({ n, label }) => { const card = mk('div', { cls: 'img-card' }); const lbl = mk('div', { cls: 'img-card-label', text: label }); const status = mk('p', { cls: 'img-status', text: '' }); const retryBtn = mk('button', { cls: 'btn btn-secondary img-retry', text: 'Check again', style: 'display:none;' }); const img = document.createElement('img'); img.alt = label; function loadImage() { status.textContent = ''; retryBtn.style.display = 'none'; img.style.display = 'block'; img.src = baseUrl + n + '?t=' + Date.now(); } img.onerror = () => { img.style.display = 'none'; status.textContent = 'Generating\u2026 check back in 30\u201360 seconds.'; retryBtn.style.display = ''; }; img.onload = () => { img.style.display = 'block'; status.textContent = ''; retryBtn.style.display = 'none'; }; img.onclick = () => { if (img.src && img.naturalWidth) window.openLightbox(img.src, label); }; retryBtn.onclick = loadImage; loadImage(); ap(card, lbl, img, status, retryBtn); grid.appendChild(card); }); ap(wrapper, grid); return wrapper; } // ── Section J: Site Map ─────────────────────────────────────────── function renderSiteMap() { const wrapper = mk('div'); ap(wrapper, mk('h2', { text: 'Section J \u2014 Site Map' })); if (!currentScenario.id) { ap(wrapper, mk('p', { cls: 'img-status', text: 'Site map is generated automatically during scenario creation.' })); return wrapper; } const mapUrl = 'api/scenarios/map/' + currentScenario.id; const status = mk('p', { cls: 'img-status' }); const retryBtn = mk('button', { cls: 'btn btn-secondary', text: 'Check again', style: 'display:none;margin-top:0.5rem;' }); const hint = mk('p', { cls: 'site-map-hint', text: 'Click the map to enlarge. For training use only \u2014 fictional layout.' }); const wrap = mk('div', { cls: 'site-map-wrap' }); const img = document.createElement('img'); img.alt = 'Tactical site map'; function loadMap() { status.textContent = ''; retryBtn.style.display = 'none'; img.src = mapUrl + '?t=' + Date.now(); } img.onerror = () => { img.style.display = 'none'; status.textContent = 'Map is still generating \u2014 usually takes 10\u201320 seconds. Click \u201cCheck Again\u201d when ready.'; retryBtn.style.display = ''; }; img.onload = () => { img.style.display = 'block'; status.textContent = ''; retryBtn.style.display = 'none'; }; img.onclick = () => { if (img.naturalWidth) window.openLightbox(img.src, 'Site Map \u2014 ' + (currentScenario.config?.incidentType || '')); }; retryBtn.onclick = loadMap; loadMap(); wrap.appendChild(img); ap(wrapper, wrap, status, retryBtn, hint); return wrapper; } // ── Section K: Floor Plan ───────────────────────────────────────── function renderFloorplan() { const wrapper = mk('div'); ap(wrapper, mk('h2', { text: 'Section K \u2014 Floor Plan' })); if (!currentScenario.id) { ap(wrapper, mk('p', { cls: 'img-status', text: 'Floor plan is generated automatically for building-based scenarios during creation.' })); return wrapper; } const fpUrl = 'api/scenarios/floorplan/' + currentScenario.id; const status = mk('p', { cls: 'img-status' }); const retryBtn = mk('button', { cls: 'btn btn-secondary', text: 'Check again', style: 'display:none;margin-top:0.5rem;' }); const hint = mk('p', { cls: 'site-map-hint', text: 'Ground floor plan \u2014 fictional layout for training use only. Click to enlarge.' }); const wrap = mk('div', { cls: 'site-map-wrap' }); const img = document.createElement('img'); img.alt = 'Building floor plan'; function loadFP() { status.textContent = ''; retryBtn.style.display = 'none'; img.src = fpUrl + '?t=' + Date.now(); } img.onerror = () => { img.style.display = 'none'; const incType = currentScenario.config?.incidentType || ''; const isOutdoor = /road traffic|rtc|motorway|aircraft|mine shaft|open water|flooding/i.test(incType); status.textContent = isOutdoor ? 'No floor plan \u2014 this appears to be an outdoor incident without a building.' : 'Floor plan still generating \u2014 usually takes 10\u201320 seconds. Click \u201cCheck Again\u201d when ready.'; if (!isOutdoor) retryBtn.style.display = ''; }; img.onload = () => { img.style.display = 'block'; status.textContent = ''; retryBtn.style.display = 'none'; }; img.onclick = () => { if (img.naturalWidth) window.openLightbox(img.src, 'Floor Plan \u2014 ' + (currentScenario.config?.incidentType || '')); }; retryBtn.onclick = loadFP; loadFP(); wrap.appendChild(img); ap(wrapper, wrap, status, retryBtn, hint); return wrapper; } // ── Section L: SSRI ────────────────────────────────────────────── function renderSSRI() { const wrapper = mk('div'); ap(wrapper, mk('h2', { text: 'Section L \u2014 SSRI' })); if (!currentScenario.id) { ap(wrapper, mk('p', { cls: 'img-status', text: 'SSRI is generated automatically for commercial and large building scenarios during creation.' })); return wrapper; } const status = mk('p', { cls: 'img-status', text: 'Loading\u2026' }); const retryBtn = mk('button', { cls: 'btn btn-secondary', text: 'Check again', style: 'display:none;margin-top:0.5rem;' }); // Sandboxed iframe — sandbox without allow-scripts prevents JS execution in the loaded HTML const frame = document.createElement('iframe'); frame.sandbox = 'allow-same-origin'; frame.style.cssText = 'width:100%;border:none;border-radius:8px;margin-top:1rem;min-height:900px;display:none;background:#fff;'; frame.title = 'SSRI Document'; async function loadSSRI() { status.textContent = 'Loading\u2026'; retryBtn.style.display = 'none'; frame.style.display = 'none'; try { const resp = await fetch('api/scenarios/ssri/' + currentScenario.id + '?t=' + Date.now()); if (resp.status === 404) { const inc = (currentScenario.config?.incidentType || '').toLowerCase(); const isCommercial = /warehouse|factory|industrial|hotel|school|hospital|office|retail|care home|block of flats/i.test(inc); status.textContent = isCommercial ? 'SSRI still generating \u2014 usually takes 10\u201320 seconds. Click \u201cCheck Again\u201d when ready.' : 'No SSRI \u2014 this scenario does not involve a commercial or large building.'; if (isCommercial) retryBtn.style.display = ''; return; } if (!resp.ok) throw new Error('HTTP ' + resp.status); const html = await resp.text(); frame.srcdoc = html; // srcdoc in sandboxed iframe — scripts cannot execute frame.style.display = 'block'; status.textContent = ''; } catch (err) { status.textContent = 'Failed to load SSRI: ' + err.message; retryBtn.style.display = ''; } } retryBtn.onclick = loadSSRI; loadSSRI(); ap(wrapper, status, retryBtn, frame); return wrapper; } // ── Section M: VR Info ─────────────────────────────────────────────── function renderVRInfo() { const wrapper = mk('div'); ap(wrapper, mk('h2', { text: 'Section M \u2014 VR Info' })); const intro = mk('p', { style: 'color:var(--muted);font-size:0.85rem;margin-bottom:1rem;' }); intro.textContent = 'Asset manifest and event log for XVR simulation setup \u2014 vehicles, personnel, equipment, buildings, and all events with what/where/when/who.'; wrapper.appendChild(intro); if (!currentScenario.id) { ap(wrapper, mk('p', { cls: 'img-status', text: 'VR Info is generated automatically during scenario creation.' })); return wrapper; } const status = mk('p', { cls: 'img-status', text: 'Loading\u2026' }); const retryBtn = mk('button', { cls: 'btn btn-secondary', text: 'Check again', style: 'display:none;margin-top:0.5rem;' }); const genBtn = mk('button', { cls: 'btn btn-primary', text: 'Generate VR info', style: 'display:none;margin-top:0.5rem;' }); const frame = document.createElement('iframe'); frame.sandbox = 'allow-same-origin'; frame.style.cssText = 'width:100%;border:none;border-radius:8px;margin-top:1rem;min-height:700px;display:none;background:#fff;'; frame.title = 'VR Asset Manifest'; async function loadVRInfo() { status.textContent = 'Loading\u2026'; retryBtn.style.display = 'none'; genBtn.style.display = 'none'; frame.style.display = 'none'; try { const resp = await fetch('api/scenarios/vrinfo/' + currentScenario.id + '?t=' + Date.now()); if (resp.status === 404) { status.textContent = 'VR Info not yet generated. Click \u201cGenerate VR Info\u201d to create it (takes ~20 seconds).'; genBtn.style.display = ''; return; } if (!resp.ok) throw new Error('HTTP ' + resp.status); const html = await resp.text(); frame.srcdoc = html; frame.style.display = 'block'; status.textContent = ''; } catch (err) { status.textContent = 'Failed to load VR Info: ' + err.message; retryBtn.style.display = ''; } } async function requestGenerate() { status.textContent = 'Generating VR Info \u2014 please wait (~20\u201330 seconds)\u2026'; genBtn.style.display = 'none'; retryBtn.style.display = 'none'; frame.style.display = 'none'; try { const resp = await fetch('api/scenarios/' + currentScenario.id + '/vrinfo', { method: 'POST' }); if (!resp.ok) { const err = await resp.json().catch(() => ({ error: 'Generation failed' })); throw new Error(err.error || 'HTTP ' + resp.status); } await loadVRInfo(); } catch (err) { status.textContent = 'Generation failed: ' + err.message; genBtn.style.display = ''; } } retryBtn.onclick = loadVRInfo; genBtn.onclick = requestGenerate; loadVRInfo(); ap(wrapper, status, retryBtn, genBtn, frame); return wrapper; } // ── Print Role Cards — A5-sized cards per role ──────────────────── // Print/PDF output below is deliberately theme-independent — role cards and // scenario PDFs are white-paper documents regardless of the app's on-screen // theme, so the hardcoded hex colours in this template are intentional. function printRoleCards(secD, config, fullText) { config = config || {}; const title = extractTitle(fullText) || config.incidentType || 'Scenario'; const metaStr = [config.level, config.incidentType, config.fireService].filter(Boolean).join(' \u00b7 '); if (!secD) { alert('No role card data available.'); return; } const roles = secD.split(/(?=\n(?:#{1,4}|\*\*\d+\.|\d+\.))/m).filter(b => b.trim()); if (!roles.length) { alert('No role cards found in Section D.'); return; } const css = [ '*{box-sizing:border-box;margin:0;padding:0;}', 'body{font-family:Arial,sans-serif;font-size:9pt;color:#111;background:#fff;}', '.card{border:1.5pt solid #003366;border-radius:6pt;padding:10mm;margin-bottom:6mm;page-break-inside:avoid;}', '.card-title{font-size:12pt;font-weight:700;color:#003366;border-bottom:1pt solid #ccd;padding-bottom:4px;margin-bottom:8px;}', '.meta{font-size:6.5pt;color:#888;margin-bottom:6px;}', '.fl{font-size:6pt;text-transform:uppercase;letter-spacing:.08em;color:#003366;font-weight:700;margin-top:6px;margin-bottom:2px;}', '.fv{font-size:8pt;line-height:1.5;}', '.fv ul{padding-left:12pt;}', '.fv li{margin-bottom:1.5px;}', '@media print{@page{margin:8mm;size:A5;}body{font-size:8.5pt;}.card{margin-bottom:4mm;}}', ].join(''); function blockToHtml(rb) { const lines = rb.trim().split('\n').filter(l => l.trim()); if (!lines.length) return ''; const roleTitle = lines[0].replace(/^#{1,4}\s*|\*\*|\d+\.\s*/g, '').trim(); if (!roleTitle || roleTitle.length < 2) return ''; let html = '
' + escHtml(roleTitle) + '
'; html += '
' + escHtml(metaStr) + '
'; let inList = false; for (let i = 1; i < lines.length; i++) { const line = lines[i].trimEnd(); const headM = line.match(/^#{1,4}\s*(.+)|\*\*([^*]+)\*\*/); if (headM) { if (inList) { html += ''; inList = false; } const ht = (headM[1] || headM[2] || '').replace(/\*\*/g, '').trim(); if (ht) html += '
' + escHtml(ht) + '
'; continue; } const liM = line.match(/^[-*]\s+(.*)/); if (liM) { if (!inList) { html += ''; inList = false; } html += '

' + escHtml(line.replace(/\*\*/g,'').replace(/\*/g,'')) + '

'; } } if (inList) html += ''; html += '
'; return html; } const cardsHtml = roles.map(blockToHtml).filter(Boolean).join(''); const pageHtml = 'Role Cards \u2014 ' + escHtml(title) + '' + cardsHtml + ''; const blob = new Blob([pageHtml], { type: 'text/html' }); const url = URL.createObjectURL(blob); const win = window.open(url, '_blank'); if (win) setTimeout(() => { win.print(); URL.revokeObjectURL(url); }, 500); else URL.revokeObjectURL(url); } // ── PDF download ─────────────────────────────────────────────────── function buildPdfLine(container, text, style) { const p = document.createElement('p'); p.style.cssText = style || 'margin:3px 0;font-size:9pt;'; p.textContent = text; container.appendChild(p); } function downloadPdf() { if (!currentScenario) return; window.DonationPopup && window.DonationPopup.trigger('build-aid'); const { config, fullText } = currentScenario; const title = extractTitle(fullText) || config.incidentType; const fname = config.level + '-' + config.incidentType.replace(/[^a-z0-9]+/gi, '-') + '-' + new Date().toISOString().slice(0, 10) + '.pdf'; const container = document.createElement('div'); container.style.cssText = 'font-family:Arial,sans-serif;color:#111;background:#fff;padding:15mm 12mm;font-size:9pt;line-height:1.5;max-width:180mm;'; // Title block const titleDiv = document.createElement('div'); titleDiv.style.cssText = 'border-bottom:2px solid #003366;padding-bottom:8px;margin-bottom:14px;'; const h1 = document.createElement('h1'); h1.style.cssText = 'font-size:14pt;color:#003366;margin:0 0 3px 0;'; h1.textContent = title; const meta = document.createElement('p'); meta.style.cssText = 'font-size:8pt;color:#555;margin:0;'; meta.textContent = config.level + ' \u00b7 ' + config.incidentType + ' \u00b7 ' + (config.fireService || '') + ' \u00b7 Generated ' + new Date().toLocaleDateString('en-GB'); titleDiv.appendChild(h1); titleDiv.appendChild(meta); container.appendChild(titleDiv); // Render each section const secs = parseSections(fullText); for (const key of SEC_KEYS) { const raw = secs[key] || ''; const secH = document.createElement('h2'); secH.style.cssText = 'font-size:11pt;color:#003366;margin-top:12px;border-top:1px solid #ccd;padding-top:6px;page-break-before:always;'; secH.textContent = 'Section ' + key + ': ' + SEC_NAMES[key]; container.appendChild(secH); for (const rawLine of raw.split('\n')) { const line = rawLine.trimEnd(); if (!line) continue; const h3m = line.match(/^#{2,4}\s+(.*)/); const blm = line.match(/^\*\*([^*]+)\*\*\s*$/); const lim = line.match(/^[-*]\s+(.*)/); if (h3m || blm) { const h = document.createElement('h3'); h.style.cssText = 'font-size:9pt;color:#003366;margin-top:8px;margin-bottom:2px;'; h.textContent = (h3m ? h3m[1] : blm[1]).replace(/\*\*/g, ''); container.appendChild(h); } else if (lim) { buildPdfLine(container, '\u2022 ' + lim[1].replace(/\*\*/g, '').replace(/\*/g, ''), 'margin:1px 0 1px 12px;font-size:8.5pt;'); } else { buildPdfLine(container, line.replace(/\*\*/g, '').replace(/\*/g, '')); } } } document.body.appendChild(container); html2pdf().set({ margin: [10, 10, 10, 10], filename: fname, html2canvas: { scale: 1.5, useCORS: true }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, pagebreak: { mode: ['css', 'legacy'], before: 'h2' }, }).from(container).save().then(() => { document.body.removeChild(container); }); } // ── Markdown download (raw pack — for import into other tools / AI) ── // Downloads currentScenario.fullText as a .md file, prefixed with an HTML // comment carrying the generation inputs (config) so downstream tools have // the full provenance. Pure client-side Blob download — no server call. function downloadMarkdown() { if (!currentScenario || !currentScenario.fullText) return; window.DonationPopup && window.DonationPopup.trigger('build-aid'); const { config, fullText, id } = currentScenario; const c = config || {}; const title = extractTitle(fullText) || c.incidentType || 'scenario'; const itype = c.incidentType || 'scenario'; const fname = (c.level ? c.level + '-' : '') + itype.replace(/[^a-z0-9]+/gi, '-') + '-' + new Date().toISOString().slice(0, 10) + '.md'; const meta = [ '', '', '', ].filter(v => v !== null).join('\n'); const blob = new Blob([meta + fullText], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fname; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ── Save/load scenarios ──────────────────────────────────────────── function extractTitle(text) { // Match **Title:** (colon inside bold) or **Title**: (colon outside bold) or # heading const m = text.match(/\*\*Title:?\*\*\s*:?\s*(.+)/i) || text.match(/^#\s+(.+)/m); return m ? m[1].replace(/\*\*/g, '').trim() : null; } function loadSaved(id) { try { const data = JSON.parse(localStorage.getItem('ba_sc_' + id)); if (!data) return; currentScenario = { id: data.id || id, config: data.config, fullText: data.fullText, sections: {} }; checkStates = {}; fromLibrary = false; renderPreview(); } catch {} } function deleteSaved(id, itemEl) { try { const saved = JSON.parse(localStorage.getItem('ba_saved') || '[]'); localStorage.setItem('ba_saved', JSON.stringify(saved.filter(s => s.id !== id))); localStorage.removeItem('ba_sc_' + id); itemEl.remove(); fetch('api/scenarios/' + id, { method: 'DELETE' }).catch(() => {}); } catch {} } // ── Role card modal ──────────────────────────────────────────────── function openRoleModal(card) { const overlay = document.getElementById('role-modal-overlay'); const content = document.getElementById('role-modal-content'); content.textContent = ''; const closeBtn = mk('button', { cls: 'role-modal-close', attrs: { 'aria-label': 'Close' } }); closeBtn.textContent = '\u00d7'; closeBtn.addEventListener('click', () => closeRoleModal()); content.appendChild(closeBtn); const rb = card.dataset.block || ''; const lines = rb.split('\n').filter(l => l.trim()); // Title const title = card.querySelector('h4') ? card.querySelector('h4').textContent : ''; ap(content, mk('h3', { text: title, style: 'color:var(--cyan);margin-top:0;margin-bottom:1rem;font-size:1rem;' })); // Render structured fields let hasFields = false; for (const f of ['Objective','What They Know','What They Will Say If Asked','What They Will NOT Volunteer']) { const val = getField(lines, f); if (val) { hasFields = true; ap(content, mk('div', { cls: 'role-fl', text: f }), renderBullets(val)); } } // Fallback: render raw prose if structured fields were not found if (!hasFields && rb) { ap(content, renderProse(rb)); } overlay.classList.add('open'); document.body.style.overflow = 'hidden'; } function closeRoleModal(e) { // When triggered by clicking the overlay, only close if the backdrop itself was clicked (not the modal box) if (e && e.type === 'click' && e.target !== document.getElementById('role-modal-overlay')) return; document.getElementById('role-modal-overlay').classList.remove('open'); document.body.style.overflow = ''; } // ── Public library ───────────────────────────────────────────────── let libScenarios = []; let libFilter = 'all'; let libSort = 'newest'; let libExpanded = null; async function showLibrary() { show('pg-library'); const listEl = document.getElementById('lib-list'); const errEl = document.getElementById('lib-error'); listEl.replaceChildren(); errEl.style.display = 'none'; libScenarios = []; libFilter = 'all'; libSort = 'newest'; libExpanded = null; document.getElementById('lib-controls').style.display = 'none'; document.querySelectorAll('.lib-sort-btn').forEach((b, i) => b.classList.toggle('active', i === 0)); try { const [scenarios, feedback] = await Promise.all([ fetch('api/scenarios').then(r => r.json()), fetch('api/feedback').then(r => r.json()), ]); if (!scenarios.length) { ap(listEl, mk('div', { cls: 'lib-empty', text: 'No scenarios saved yet — generate one to get started.' })); return; } libScenarios = scenarios.map(sc => ({ ...sc, _fb: feedback[sc.id] || {} })); // Build filter buttons from incident types const types = [...new Set(libScenarios.map(sc => (sc.incidentType || 'Other').trim()))].sort(); const filterRow = document.getElementById('lib-filter-row'); filterRow.replaceChildren(); const allBtn = mk('button', { cls: 'lib-filter-btn active', text: 'All (' + libScenarios.length + ')' }); allBtn.dataset.filter = 'all'; allBtn.onclick = () => setLibFilter('all'); filterRow.appendChild(allBtn); types.forEach(t => { const count = libScenarios.filter(sc => (sc.incidentType || 'Other').trim() === t).length; const btn = mk('button', { cls: 'lib-filter-btn', text: t + ' (' + count + ')' }); btn.dataset.filter = t.toLowerCase(); btn.onclick = () => setLibFilter(t.toLowerCase()); filterRow.appendChild(btn); }); // Wire sort buttons document.querySelectorAll('.lib-sort-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.lib-sort-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); libSort = btn.dataset.sort; renderLibList(); }); }); document.getElementById('lib-controls').style.display = ''; renderLibList(); } catch (err) { errEl.textContent = 'Failed to load library: ' + err.message; errEl.style.display = ''; } } function setLibFilter(filter) { libFilter = filter; document.querySelectorAll('.lib-filter-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.filter === filter); }); libExpanded = null; renderLibList(); } function getLibFiltered() { let list = libFilter === 'all' ? libScenarios : libScenarios.filter(sc => (sc.incidentType || 'Other').trim().toLowerCase() === libFilter); if (libSort === 'popular') return [...list].sort((a, b) => ((b._fb.rating || {}).count || 0) - ((a._fb.rating || {}).count || 0)); if (libSort === 'az') return [...list].sort((a, b) => (a.title || '').localeCompare(b.title || '')); return [...list].sort((a, b) => new Date(b.savedAt || 0) - new Date(a.savedAt || 0)); } function renderLibList() { const listEl = document.getElementById('lib-list'); if (!listEl) return; listEl.replaceChildren(); const filtered = getLibFiltered(); if (!filtered.length) { ap(listEl, mk('div', { cls: 'lib-empty', text: 'No scenarios match this filter.' })); return; } const groups = {}; filtered.forEach(sc => { const key = (sc.incidentType || 'Other').trim(); const normKey = key.toLowerCase(); if (!groups[normKey]) groups[normKey] = { label: key, items: [] }; groups[normKey].items.push(sc); }); const sortedGroups = Object.values(groups).sort((a, b) => a.label.localeCompare(b.label)); const showHeaders = libFilter === 'all' || sortedGroups.length > 1; sortedGroups.forEach(group => { if (showHeaders) ap(listEl, mk('div', { cls: 'lib-group-header', text: group.label })); group.items.forEach(sc => listEl.appendChild(makeLibItem(sc, listEl))); }); } function makeLibItem(sc, listEl) { const item = mk('div', { cls: 'lib-item' }); item.dataset.id = sc.id; if (sc.id === libExpanded) item.classList.add('expanded'); const lvlCls = sc.level === 'L1' ? 'lp-l1' : sc.level === 'Minimal' ? 'lp-minimal' : 'lp-l2'; const pill = mk('span', { cls: 'level-pill ' + lvlCls, text: sc.level || '?' }); const title = mk('div', { cls: 'lib-item-title', text: sc.title || sc.incidentType }); const meta = mk('div', { cls: 'lib-item-meta', text: new Date(sc.savedAt).toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' }) + ' \u00b7 ' + sc.incidentType }); const body = mk('div', { cls: 'lib-item-body' }); ap(body, title, meta); const chevron = mk('span', { cls: 'lib-chevron', text: '\u25b6' }); const hdr = mk('div', { cls: 'lib-item-header' }); ap(hdr, pill, body, chevron); item.appendChild(hdr); const panel = mk('div', { cls: 'lib-item-panel' }); if (sc.hasImage) { const img = mk('img', { cls: 'lib-item-img' }); img.alt = ''; if (sc.id === libExpanded) { img.src = 'api/scenarios/image/' + sc.id; img.onerror = () => { img.style.display = 'none'; }; } panel.appendChild(img); } const fb = sc._fb || {}; // Stars const { starsWrap, infoEl } = makeStars(sc.id, fb); const starRow = mk('div', { cls: 'star-row' }); ap(starRow, starsWrap, infoEl); panel.appendChild(starRow); // Comments const commentsWrap = mk('div', { cls: 'comments-wrap' }); ap(commentsWrap, mk('div', { cls: 'comments-label', text: 'Comments' })); const comments = fb.comments || []; if (comments.length) { for (const c of comments) { const ci = mk('div', { cls: 'comment-item' }); ci.appendChild(document.createTextNode(c.text)); ap(ci, mk('div', { cls: 'comment-date', text: new Date(c.postedAt).toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' }) })); commentsWrap.appendChild(ci); } } else { ap(commentsWrap, mk('p', { text: 'No comments yet.', style: 'color:var(--muted);font-size:0.82rem;' })); } const form = mk('div', { cls: 'comment-form' }); const inp = mk('textarea', { cls: 'comment-input', attrs: { placeholder: 'Add a comment\u2026', rows: '2', maxlength: '500' } }); const sbmt = mk('button', { cls: 'btn btn-secondary btn-comment', text: 'Post' }); sbmt.onclick = () => submitComment(sc.id, inp, commentsWrap); ap(form, inp, sbmt); commentsWrap.appendChild(form); panel.appendChild(commentsWrap); // Actions const actions = mk('div', { cls: 'lib-actions' }); const viewBtn = mk('button', { cls: 'btn btn-primary', text: 'View scenario' }); viewBtn.onclick = e => { e.stopPropagation(); loadAndView(sc); }; const dlBtn = mk('button', { cls: 'btn btn-secondary', text: 'Download PDF' }); dlBtn.onclick = e => { e.stopPropagation(); loadAndDownload(sc); }; const dlMdBtn = mk('button', { cls: 'btn btn-secondary', text: 'Download .md', attrs: { title: 'Raw markdown — for import into other tools / AI' } }); dlMdBtn.onclick = e => { e.stopPropagation(); loadAndDownloadMd(sc); }; ap(actions, viewBtn, dlBtn, dlMdBtn); panel.appendChild(actions); item.appendChild(panel); item.addEventListener('click', () => { const opening = !item.classList.contains('expanded'); listEl.querySelectorAll('.lib-item.expanded').forEach(i => i.classList.remove('expanded')); libExpanded = null; if (opening) { item.classList.add('expanded'); libExpanded = sc.id; if (sc.hasImage) { const img = panel.querySelector('.lib-item-img'); if (img && !img.src) { img.src = 'api/scenarios/image/' + sc.id; img.onerror = () => { img.style.display = 'none'; }; } } } }); return item; } function makeStars(id, fb) { const r = fb.rating || { avg: 0, count: 0 }; const starsWrap = mk('div', { cls: 'stars-wrap' }); const infoEl = mk('span', { cls: 'rating-info', text: r.count ? (r.avg + ' \u2605 (' + r.count + ')') : 'No ratings yet' }); starsWrap.dataset.rating = '0'; for (let i = 1; i <= 5; i++) { const btn = mk('button', { cls: 'star-btn', text: '\u2605' }); const star = i; btn.onmouseover = () => { starsWrap.querySelectorAll('.star-btn').forEach((s, idx) => s.classList.toggle('lit', idx < star)); }; btn.onmouseout = () => { const cur = parseInt(starsWrap.dataset.rating || '0', 10); starsWrap.querySelectorAll('.star-btn').forEach((s, idx) => s.classList.toggle('lit', idx < cur)); }; btn.onclick = () => submitRating(id, star, starsWrap, infoEl); starsWrap.appendChild(btn); } return { starsWrap, infoEl }; } async function submitRating(id, rating, starsWrap, infoEl) { try { const res = await fetch('api/feedback/' + id + '/rate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rating }), }); const data = await res.json(); if (!res.ok) { infoEl.textContent = data.error || 'Error saving rating'; return; } starsWrap.dataset.rating = String(rating); starsWrap.querySelectorAll('.star-btn').forEach((s, idx) => s.classList.toggle('lit', idx < rating)); infoEl.textContent = data.avg + ' \u2605 (' + data.count + ')'; } catch { infoEl.textContent = 'Error saving rating'; } } async function submitComment(id, inputEl, commentsWrap) { const text = inputEl.value.trim(); if (!text) return; try { const res = await fetch('api/feedback/' + id + '/comment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }); const data = await res.json(); if (!res.ok) return; const ci = mk('div', { cls: 'comment-item' }); ci.appendChild(document.createTextNode(data.text)); ap(ci, mk('div', { cls: 'comment-date', text: new Date(data.postedAt).toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' }) })); const form = commentsWrap.querySelector('.comment-form'); const noCom = commentsWrap.querySelector('p'); if (noCom) noCom.remove(); commentsWrap.insertBefore(ci, form); inputEl.value = ''; } catch {} } async function loadAndView(sc) { try { const data = await fetch('api/scenarios/' + sc.id).then(r => r.json()); currentScenario = { id: data.id, config: data.config || { level: data.level, incidentType: data.incidentType }, fullText: data.fullText, sections: {}, }; checkStates = {}; fromLibrary = true; renderPreview(); } catch {} } async function loadAndDownload(sc) { try { const data = await fetch('api/scenarios/' + sc.id).then(r => r.json()); currentScenario = { config: data.config || { level: data.level, incidentType: data.incidentType }, fullText: data.fullText, sections: {}, }; downloadPdf(); } catch {} } async function loadAndDownloadMd(sc) { try { const data = await fetch('api/scenarios/' + sc.id).then(r => r.json()); currentScenario = { id: data.id, config: data.config || { level: data.level, incidentType: data.incidentType }, fullText: data.fullText, sections: {}, }; downloadMarkdown(); } catch {} } // ── Init ─────────────────────────────────────────────────────────── loadIncidentTypes(); loadQuota(); loadNfccStatus(); if (localStorage.getItem('buildaid_banner_dismissed') === '1') { const b = document.getElementById('disclaimerBanner'); if (b) b.style.display = 'none'; }