/* 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 `
Channel
Frequency
Amplitude/Ratio
Result
${_sensorRow('Transverse', sc.tran)}
${_sensorRow('Vertical', sc.vert)}
${_sensorRow('Longitudinal', sc.long)}
${_sensorRow('Microphone', sc.mic)}
`;
}
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 `
Notes
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
Save
`;
}
// ── 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 = `
Sidecar JSON
Copy
${_esc(JSON.stringify(s, null, 2))}
`;
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 = `
`;
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();
});
})();