/* event-modal.js — shared event-detail modal. * * Used by: * - /sfm (admin Events tab) * - /projects/{p}/nrl/{l} (project-location Events tab) * - /unit/{id} (unit-detail SFM Events table) * * Pages must include partials/event_detail_modal.html in the body * before this script is loaded. * * Public API: * showEventDetail(eventId) * Open the modal and fetch /api/sfm/db/events/{id}/sidecar to * populate the rich BW report fields (peaks, ZC freq, sensor * self-check, device info, etc.) into a tabbed/sectioned view. * * closeEventDetailModal() * Close the modal. * * Notes: * - Fetches sidecar live from SFM via terra-view's /api/sfm proxy. * - Renders gracefully when the sidecar lacks a bw_report block * (older events forwarded before the _ASCII.TXT pairing fix). * - All functions are global on window so inline onclick handlers * can reach them across all three host pages. */ (function () { const MODAL_ID = 'event-detail-modal'; // ── Chart.js constants (ported from sfm_webapp.html:2555-2880) ── const _CHANNEL_COLORS = { MicL: '#e066ff', // purple — distinct from the geo channels Long: '#3b82f6', // blue Vert: '#22c55e', // green Tran: '#ef4444', // red }; const _CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran']; // dB(L) reference pressure — 20 µPa expressed in psi (Instantel native unit). const DBL_REF = 2.9e-9; // Mic display floor — sound-pressure AC samples sit at the digitisation // noise floor most of the time (1-2 ADC counts ≈ 20-40 dBL). Without // a floor, the chart looks like a sparse pattern of "moments when sound // briefly exceeded the Y-axis bottom" instead of an SPL-vs-time curve. const MIC_DBL_FLOOR = 60; let _charts = {}; // ch → Chart instance let _micUnitPref = 'dBL'; // refreshed via fetch on first chart render let _micUnitPrefLoaded = false; // one-shot fetch guard function _esc(s) { if (s == null) return ''; return String(s).replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function _fmt(v, digits = 4, suffix = '') { if (v == null || (typeof v === 'number' && Number.isNaN(v))) return '—'; if (typeof v === 'number') { return v.toFixed(digits) + (suffix ? ` ${suffix}` : ''); } return _esc(v) + (suffix ? ` ${suffix}` : ''); } function _ppvClass(v) { if (v == null) return 'text-gray-400'; if (v < 0.5) return 'text-green-600 dark:text-green-400'; if (v < 2.0) return 'text-amber-600 dark:text-amber-400'; return 'text-red-600 dark:text-red-400 font-semibold'; } function _kvCard(label, value, options = {}) { // Single key-value tile. `value` is pre-rendered HTML (or text). const colorCls = options.colorCls || ''; const valCls = `font-mono font-semibold ${colorCls}`; return `
${_esc(label)}
${value}
${options.sub ? `
${options.sub}
` : ''}
`; } function _deriveRecordType(filename, fallback) { // SFM currently hardcodes record_type="Waveform" for every event. // The actual type is encoded in the LAST character of the Blastware // filename's extension (e.g. "O121LL5E.IS0H" → "H" → Histogram). // We derive it client-side until SFM is fixed; if the suffix isn't // a known code we fall back to whatever SFM reported. if (!filename) return fallback || '—'; const dotIdx = filename.lastIndexOf('.'); if (dotIdx < 0 || dotIdx === filename.length - 1) return fallback || '—'; const ext = filename.slice(dotIdx + 1); const lastChar = ext.slice(-1).toUpperCase(); const typeMap = { 'H': 'Histogram', 'W': 'Waveform', 'M': 'Manual', 'E': 'Event', 'C': 'Combo', }; return typeMap[lastChar] || (fallback || '—'); } function _sectionHeader(title, sub) { return `

${_esc(title)}${sub ? ` ${_esc(sub)}` : ''}

`; } // ── Section renderers ──────────────────────────────────────────── function _renderEventHeader(s) { const ev = s.event || {}; const bw = s.blastware || {}; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; const recType = _deriveRecordType(bw.filename || ev.blastware_filename, ev.record_type); return `
Serial ${_esc(ev.serial)}
Timestamp ${ts}
Record Type ${_esc(recType)}
Sample Rate ${ev.sample_rate ?? '—'} sps
Rec Time ${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}
Waveform Key ${_esc(ev.waveform_key || '—')}
`; } function _renderUserNotes(s) { // The "user notes" metadata the operator typed into the BW device. // These are the strings the future metadata-driven parser will use. // NOTE: SFM's sidecar JSON still names this block `project_info` — // we render it as "User Notes" (the actual BW term) but read the // field by its SFM-API name. Rename in SFM is a future cleanup. const p = s.project_info || {}; return `
Project ${_esc(p.project || '—')}
Client ${_esc(p.client || '—')}
Operator ${_esc(p.operator || '—')}
Sensor Location ${_esc(p.sensor_location || '—')}

Values are as typed into the seismograph at session start — not the terra-view project/location assignment.

`; } function _renderPeakValues(s) { // Prefer bw_report.peaks for richer per-channel data; fall back to peak_values. const bwPeaks = (s.bw_report && s.bw_report.peaks) || null; const pv = s.peak_values || {}; const tran = bwPeaks ? bwPeaks.tran?.ppv_ips : pv.transverse; const vert = bwPeaks ? bwPeaks.vert?.ppv_ips : pv.vertical; const lng = bwPeaks ? bwPeaks.long?.ppv_ips : pv.longitudinal; const pvs = bwPeaks ? bwPeaks.vector_sum?.ips : pv.vector_sum; const pvsAt = bwPeaks ? bwPeaks.vector_sum?.time_s : null; return `
${_kvCard('Transverse', `${_fmt(tran, 4)}`, { sub: 'in/s' })} ${_kvCard('Vertical', `${_fmt(vert, 4)}`, { sub: 'in/s' })} ${_kvCard('Longitudinal', `${_fmt(lng, 4)}`, { sub: 'in/s' })} ${_kvCard('Peak Vector Sum', `${_fmt(pvs, 4)}`, { sub: pvsAt != null ? `in/s @ t=${_fmt(pvsAt, 2)}s` : 'in/s', })}
`; } function _renderMic(s) { // Operators only care about dB(L); PSI tile was dropped 2026-05. // We still render the row if any mic data is present so ZC freq / // time-of-peak stay visible even when bw_report.mic is missing. const mic = (s.bw_report && s.bw_report.mic) || null; const pv = s.peak_values || {}; if (!mic && pv.mic_psi == null) return ''; const dbl = mic?.pspl_dbl; const zcHz = mic?.zc_freq_hz; const tPk = mic?.time_of_peak_s; const wt = mic?.weighting; return `
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })} ${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))} ${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
`; } function _sensorRow(label, ch) { if (!ch) { return `${_esc(label)} —`; } const result = ch.result || '—'; const resultCls = result === 'Passed' ? 'text-green-600 dark:text-green-400' : (result === 'Failed' ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-500'); // Geo channels have freq + ratio; mic has freq + amplitude. const rightCol = (ch.amplitude_mv != null) ? `${_fmt(ch.amplitude_mv, 1, 'mV')}` : `${ch.ratio != null ? ch.ratio.toFixed(1) + ' ratio' : '—'}`; return ` ${_esc(label)} ${_fmt(ch.freq_hz, 1, 'Hz')} ${rightCol} ${_esc(result)} `; } function _renderSensorCheck(s) { const sc = s.bw_report && s.bw_report.sensor_check; if (!sc) return ''; return ` ${_sensorRow('Transverse', sc.tran)} ${_sensorRow('Vertical', sc.vert)} ${_sensorRow('Longitudinal', sc.long)} ${_sensorRow('Microphone', sc.mic)}
Channel Frequency Amplitude/Ratio Result
`; } function _renderDeviceMetadata(s) { const bw = s.bw_report || {}; const dev = bw.device || {}; const rec = bw.recording || {}; return `
Firmware ${_esc(bw.version || '—')}
Battery ${dev.battery_volts != null ? dev.battery_volts.toFixed(2) + ' V' : '—'}
Calibrated ${_esc(dev.calibration_date || '—')}${dev.calibration_by ? ' (' + _esc(dev.calibration_by) + ')' : ''}
Geo Range ${rec.geo_range_ips != null ? rec.geo_range_ips + ' in/s' : '—'}
Stop Mode ${_esc(rec.stop_mode || '—')}
Units ${_esc(rec.units || '—')}
`; } function _renderReview(s, eventId) { const rev = s.review || {}; const ft = !!rev.false_trigger; const reviewer = rev.reviewer || ''; const notes = rev.notes || ''; const reviewedAt = rev.reviewed_at ? rev.reviewed_at.replace('T', ' ').slice(0, 19) : null; return `
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
`; } // ── Waveform / histogram chart helpers ────────────────────────── async function _loadMicUnitPref() { if (_micUnitPrefLoaded) return _micUnitPref; try { const r = await fetch('/api/settings/preferences'); if (r.ok) { const prefs = await r.json(); _micUnitPref = prefs.mic_unit_pref === 'psi' ? 'psi' : 'dBL'; } } catch (e) { // Network error → silent fall back to default 'dBL'. } _micUnitPrefLoaded = true; return _micUnitPref; } function _psiToDbl(psi) { if (psi == null || !(psi > 0)) return null; return 20 * Math.log10(psi / DBL_REF); } // Rectifying psi→dBL converter for per-sample values — see comments in // sfm_webapp.html:2592-2607 for the floor rationale. function _psiToDblForChart(psi) { if (psi == null) return MIC_DBL_FLOOR; const a = Math.abs(psi); if (a === 0) return MIC_DBL_FLOOR; const dbl = 20 * Math.log10(a / DBL_REF); return dbl > MIC_DBL_FLOOR ? dbl : MIC_DBL_FLOOR; } // Adaptive decimal formatter — sensible precision in the normal range, // scientific notation only at the extremes. function _fmtPeak(v, unit) { if (v == null || (typeof v === 'number' && !isFinite(v))) return ''; if (typeof v !== 'number') return String(v) + (unit ? ' ' + unit : ''); if (v === 0) return '0' + (unit ? ' ' + unit : ''); const a = Math.abs(v); const u = unit ? ' ' + unit : ''; if (a >= 0.0001 && a < 10000) { const d = a >= 100 ? 1 : a >= 10 ? 2 : a >= 1 ? 3 : a >= 0.1 ? 4 : 5; return v.toFixed(d) + u; } return v.toExponential(2) + u; } function _destroyCharts() { Object.values(_charts).forEach(c => { try { c.destroy(); } catch (e) { /* noop */ } }); _charts = {}; } // Returns true when Tailwind dark mode is active (the `dark` class is // toggled on by Terra-View's theme handler). Drives chart grid // + tick colors so they have contrast on both backgrounds. function _isDark() { return document.documentElement.classList.contains('dark'); } function _renderWaveformInto(containerId, data, micUnit) { const container = document.getElementById(containerId); if (!container) return; container.innerHTML = ''; _destroyCharts(); const channels = data.channels || {}; const ta = data.time_axis || {}; const sr = ta.sample_rate || 1024; const dtMs = ta.dt_ms || (1000.0 / sr); const t0Ms = ta.t0_ms != null ? ta.t0_ms : 0; const isHistogram = String(data.record_type || '').toLowerCase().includes('histogram'); const withData = _CHANNEL_ORDER.filter(ch => channels[ch] && (channels[ch].values || []).length > 0 ); const lastCh = withData[withData.length - 1]; // Theme-aware chart colors. Tailwind dark uses bg-slate-800 (~#1e293b); // light is white. Grids + ticks need contrast on both. const dark = _isDark(); const gridColor = dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; const tickColor = dark ? '#94a3b8' : '#64748b'; if (withData.length === 0) { container.innerHTML = `
No waveform samples decoded — codec walker returned 0 valid blocks for this event.
`; return; } for (const ch of _CHANNEL_ORDER) { const chData = channels[ch]; if (!chData) continue; let values = chData.values || []; let chUnit = chData.unit || ''; let chPeak = chData.peak; // Mic: convert psi → dBL when the user pref is dBL (default). if (ch === 'MicL' && chUnit === 'psi' && micUnit === 'dBL') { values = values.map(_psiToDblForChart); chPeak = _psiToDbl(chPeak); chUnit = 'dB(L)'; } const wrap = document.createElement('div'); wrap.className = 'bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-md px-3 pr-8 pb-1 pt-1 mb-1'; const lbl = document.createElement('div'); lbl.className = 'text-[10px] font-semibold uppercase tracking-wider mb-0.5 flex justify-between items-baseline'; lbl.style.color = _CHANNEL_COLORS[ch]; const peakStr = chPeak != null ? `peak ${_fmtPeak(chPeak, chUnit)}` : ''; lbl.innerHTML = `${ch}${peakStr}`; wrap.appendChild(lbl); if (values.length === 0) { const e = document.createElement('div'); e.className = 'h-20 flex items-center justify-center text-xs text-gray-400 italic'; e.textContent = 'no samples decoded'; wrap.appendChild(e); container.appendChild(wrap); continue; } const canvasWrap = document.createElement('div'); canvasWrap.className = 'relative'; canvasWrap.style.height = '100px'; const canvas = document.createElement('canvas'); canvasWrap.appendChild(canvas); wrap.appendChild(canvasWrap); container.appendChild(wrap); // X-axis: waveforms use ms-relative-to-trigger; histograms use // the BW-reported interval timestamps (HH:MM:SS) when the server // aggregated to BW intervals, else interval index. let times; if (isHistogram) { const intervalTimes = ta.interval_times || []; times = (intervalTimes.length === values.length) ? intervalTimes : values.map((_, i) => i + 1); } else { times = values.map((_, i) => t0Ms + i * dtMs); } // Downsample for rendering when very long. const MAX = 3000; let rT = times, rV = values; if (values.length > MAX) { const step = Math.ceil(values.length / MAX); rT = times.filter((_, i) => i % step === 0); rV = values.filter((_, i) => i % step === 0); } const showX = (ch === lastCh); const xAxisLabel = isHistogram ? '' : ' ms'; const fmtTick = i => { const v = rT[i]; if (typeof v === 'number') { const s = Number.isInteger(v) ? String(v) : v.toFixed(1); return s + xAxisLabel; } return String(v) + xAxisLabel; }; // Y-axis bounds — see sfm_webapp.html:2744-2786 for the rationale. let yBounds = {}; const isGeo = ch !== 'MicL'; if (isGeo && !isHistogram) { let absMax = 0; for (const v of values) { const a = Math.abs(v); if (a > absMax) absMax = a; } const padded = (absMax || 1) * 1.10; yBounds = { min: -padded, max: padded }; } else if (isGeo && isHistogram) { const HIST_GEO_MIN_INS = 0.05; let peak = 0; for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; } yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_GEO_MIN_INS) }; } else if (ch === 'MicL' && micUnit === 'dBL') { const peakDbl = (typeof chPeak === 'number' && isFinite(chPeak)) ? chPeak + 5 : 100; yBounds = { min: MIC_DBL_FLOOR, max: Math.max(peakDbl, MIC_DBL_FLOOR + 20) }; } else if (ch === 'MicL' && isHistogram && micUnit === 'psi') { const HIST_MIC_MIN_PSI = 0.001; let peak = 0; for (const v of values) { const a = Math.abs(v); if (a > peak) peak = a; } yBounds = { min: 0, max: Math.max(peak * 1.10, HIST_MIC_MIN_PSI) }; } _charts[ch] = new Chart(canvas, { type: isHistogram ? 'bar' : 'line', data: { labels: rT.map(t => (typeof t === 'number' ? (Number.isInteger(t) ? String(t) : t.toFixed(2)) : t)), datasets: isHistogram ? [{ data: rV, backgroundColor: _CHANNEL_COLORS[ch], borderWidth: 0, barPercentage: 1.0, categoryPercentage: 1.0, }] : [{ data: rV, borderColor: _CHANNEL_COLORS[ch], borderWidth: 1, pointRadius: 0, tension: 0, }], }, options: { animation: false, responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { mode: 'index', intersect: false, callbacks: { title: items => isHistogram ? `interval ${items[0].label}` : `t = ${items[0].label} ms`, label: item => `${ch}: ${_fmtPeak(item.raw, chUnit)}`, }, }, }, scales: { x: { type: 'category', display: showX, ticks: { color: tickColor, maxTicksLimit: 8, maxRotation: 0, callback: (v, i) => fmtTick(i) }, grid: { color: gridColor, drawTicks: showX }, }, y: { ...yBounds, ticks: { color: tickColor, maxTicksLimit: 4 }, grid: { color: gridColor }, title: { display: true, text: chUnit, color: tickColor, font: { size: 9 } }, }, }, }, plugins: isHistogram ? [] : [{ id: 'overlays', afterDraw(chart) { const ctx = chart.ctx, x = chart.scales.x, y = chart.scales.y; const zi = rT.findIndex(t => parseFloat(t) >= 0); if (zi >= 0) { const px = x.getPixelForValue(zi); ctx.save(); ctx.beginPath(); ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom); ctx.strokeStyle = 'rgba(239,68,68,0.8)'; ctx.lineWidth = 1.2; ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore(); ctx.save(); ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.moveTo(px - 4, y.top - 7); ctx.lineTo(px + 4, y.top - 7); ctx.lineTo(px, y.top - 1); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.moveTo(px - 4, y.bottom + 7); ctx.lineTo(px + 4, y.bottom + 7); ctx.lineTo(px, y.bottom + 1); ctx.closePath(); ctx.fill(); ctx.restore(); } const zy = y.getPixelForValue(0); if (zy >= y.top && zy <= y.bottom) { ctx.save(); ctx.strokeStyle = gridColor; ctx.lineWidth = 0.8; ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.moveTo(x.left, zy); ctx.lineTo(x.right, zy); ctx.stroke(); ctx.restore(); ctx.save(); ctx.fillStyle = tickColor; ctx.font = '10px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText('0.0', x.right + 6, zy); ctx.restore(); } }, }], }); } } function _renderFileInfo(s, eventId) { const bw = s.blastware || {}; const src = s.source || {}; const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null; const canDownloadBinary = !!(bw.available && bw.filename && eventId); const txtFilename = src && src.txt_filename; const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`; const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`; const downloadButtons = `
Download PDF ${canDownloadBinary ? ` Blastware binary ${sizeKb ? `(${sizeKb} KB)` : ''} ` : ''} ${txtFilename ? ` Original .TXT report ` : ''} Download sidecar JSON
`; return `${downloadButtons}
Blastware file ${_esc(bw.filename || '—')} ${sizeKb ? `(${sizeKb} KB)` : ''}
SHA-256 ${_esc(bw.sha256 || '—')}
Captured at ${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}
Tool version ${_esc(src.tool_version || '—')}
`; } // ── Public API ─────────────────────────────────────────────────── window.showEventDetail = async function (eventId) { const modal = document.getElementById(MODAL_ID); if (!modal) { console.warn('event-modal: include event_detail_modal.html partial on this page.'); return; } modal.classList.remove('hidden'); document.getElementById(MODAL_ID + '-title').textContent = 'Event Detail'; document.getElementById(MODAL_ID + '-content').innerHTML = `
Loading event detail…
`; let s; try { const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`); if (!r.ok) { throw new Error('HTTP ' + r.status + ' fetching sidecar'); } s = await r.json(); } catch (e) { document.getElementById(MODAL_ID + '-content').innerHTML = `
Failed to load event detail: ${_esc(e.message)}
`; return; } const ev = s.event || {}; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : ''; document.getElementById(MODAL_ID + '-title').textContent = `Event — ${ev.serial || '?'} @ ${ts}`; const hasReport = !!s.bw_report; const reportNote = hasReport ? '' : `
No BW ASCII report paired with this event. Older events forwarded before the watcher's _ASCII.TXT pairing fix landed lack this data. PPV is still available from the binary event file.
`; document.getElementById(MODAL_ID + '-content').innerHTML = ` ${reportNote} ${_sectionHeader('Event')} ${_renderEventHeader(s)} ${_sectionHeader('User Notes')} ${_renderUserNotes(s)} ${_sectionHeader('Peak Particle Velocity')} ${_renderPeakValues(s)} ${_sectionHeader('Waveform')}
Loading waveform…
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? ` ${_sectionHeader('Microphone')} ${_renderMic(s)} ` : ''} ${hasReport ? ` ${_sectionHeader('Sensor Self-Check')} ${_renderSensorCheck(s)} ${_sectionHeader('Device & Recording Metadata')} ${_renderDeviceMetadata(s)} ` : ''} ${_sectionHeader('Review')} ${_renderReview(s, eventId)} ${_sectionHeader('Source File')} ${_renderFileInfo(s, eventId)} `; // Waveform load runs after the sidecar content is in the DOM, in // parallel with the mic-unit-pref fetch. Either may complete first. try { const [wfRes, micUnit] = await Promise.all([ fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/waveform.json`), _loadMicUnitPref(), ]); if (wfRes.status === 404) { document.getElementById('event-waveform-status').textContent = 'No waveform data — codec returned 0 valid blocks for this event.'; return; } if (!wfRes.ok) { document.getElementById('event-waveform-status').textContent = 'Failed to load waveform: HTTP ' + wfRes.status; return; } const wfData = await wfRes.json(); document.getElementById('event-waveform-status').textContent = ''; _renderWaveformInto('event-waveform-charts', wfData, micUnit); } catch (e) { const st = document.getElementById('event-waveform-status'); if (st) st.textContent = 'Waveform fetch failed: ' + _esc(e.message); } }; window.closeEventDetailModal = function () { const modal = document.getElementById(MODAL_ID); if (modal) modal.classList.add('hidden'); _destroyCharts(); }; window.toggleEventJsonViewer = function () { const viewer = document.getElementById('event-json-viewer'); const label = document.getElementById('event-json-toggle-label'); if (!viewer) return; const isHidden = viewer.classList.toggle('hidden'); if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON'; }; window.toggleEventPdfPreview = function () { const preview = document.getElementById('event-pdf-preview'); const iframe = document.getElementById('event-pdf-iframe'); const label = document.getElementById('event-pdf-toggle-label'); if (!preview || !iframe) return; const isHidden = preview.classList.toggle('hidden'); // Lazy-load the PDF: only set the iframe src on first reveal, so // closing the event modal without opening the PDF never spends // bandwidth on it. if (!isHidden && !iframe.src) { iframe.src = iframe.dataset.pdfUrl || ''; } if (label) label.textContent = isHidden ? 'Show Event Report PDF' : 'Hide Event Report PDF'; // Scroll the iframe into view on first reveal so the operator // doesn't have to hunt for it after clicking. if (!isHidden) { preview.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }; window.saveEventReview = async function (eventId) { const ft = document.getElementById('event-review-ft'); const reviewer = document.getElementById('event-review-reviewer'); const notes = document.getElementById('event-review-notes'); const status = document.getElementById('event-review-status'); if (!ft || !reviewer || !notes) return; const payload = { review: { false_trigger: ft.checked, reviewer: reviewer.value.trim() || null, notes: notes.value.trim() || null, } }; if (status) { status.textContent = 'Saving…'; status.className = 'text-xs text-gray-500 dark:text-gray-400'; } try { const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!r.ok) { const t = await r.text().catch(() => ''); throw new Error('HTTP ' + r.status + (t ? ` — ${t.slice(0, 120)}` : '')); } if (status) { status.textContent = 'Saved.'; status.className = 'text-xs text-green-600 dark:text-green-400'; } // Notify the host page so its event-list FT badge / row state // can refresh. Pages opt in by listening for this event. window.dispatchEvent(new CustomEvent('sfm-event-review-saved', { detail: { eventId, review: payload.review }, })); } catch (e) { if (status) { status.textContent = 'Save failed: ' + e.message; status.className = 'text-xs text-red-600 dark:text-red-400'; } } }; window.copyEventJson = function () { const pre = document.getElementById('event-json-pre'); const label = document.getElementById('event-json-copy-label'); if (!pre) return; navigator.clipboard.writeText(pre.textContent).then(() => { if (label) { label.textContent = 'Copied!'; setTimeout(() => { label.textContent = 'Copy'; }, 1500); } }).catch(err => { console.error('clipboard write failed', err); if (label) { label.textContent = 'Failed'; setTimeout(() => { label.textContent = 'Copy'; }, 1500); } }); }; // Close on Escape. document.addEventListener('keydown', function (e) { if (e.key === 'Escape') window.closeEventDetailModal(); }); })();