diff --git a/backend/static/event-modal.js b/backend/static/event-modal.js
new file mode 100644
index 0000000..51d56fd
--- /dev/null
+++ b/backend/static/event-modal.js
@@ -0,0 +1,295 @@
+/* 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';
+
+ 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 _sectionHeader(title, sub) {
+ return `
+ ${_esc(title)}${sub ? ` ${_esc(sub)} ` : ''}
+ `;
+ }
+
+ // ── Section renderers ────────────────────────────────────────────
+
+ function _renderEventHeader(s) {
+ const ev = s.event || {};
+ const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
+ return `
+
Serial ${_esc(ev.serial)}
+
Timestamp ${ts}
+
Record Type ${_esc(ev.record_type || '—')}
+
Sample Rate ${ev.sample_rate ?? '—'} sps
+
Rec Time ${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}
+
Waveform Key ${_esc(ev.waveform_key || '—')}
+
`;
+ }
+
+ function _renderProjectInfo(s) {
+ // The "user notes" metadata the operator typed into the BW device.
+ // These are the strings the future metadata-driven parser will use.
+ 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) {
+ 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 psi = pv.mic_psi;
+ 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('Peak Mic psi', _fmt(psi, 4))}
+ ${_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 _renderFileInfo(s) {
+ const bw = s.blastware || {};
+ const src = s.source || {};
+ return `
+
Blastware file ${_esc(bw.filename || '—')} ${bw.filesize ? `(${(bw.filesize / 1024).toFixed(1)} 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('Project Info', '(operator-typed at session start)')}
+ ${_renderProjectInfo(s)}
+
+ ${_sectionHeader('Peak Particle Velocity')}
+ ${_renderPeakValues(s)}
+
+ ${(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('Source File')}
+ ${_renderFileInfo(s)}
+ `;
+ };
+
+ window.closeEventDetailModal = function () {
+ const modal = document.getElementById(MODAL_ID);
+ if (modal) modal.classList.add('hidden');
+ };
+
+ // Close on Escape.
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') window.closeEventDetailModal();
+ });
+})();
diff --git a/templates/partials/event_detail_modal.html b/templates/partials/event_detail_modal.html
new file mode 100644
index 0000000..68dc574
--- /dev/null
+++ b/templates/partials/event_detail_modal.html
@@ -0,0 +1,25 @@
+{# Shared event detail modal.
+
+Include this partial on any page that wants to call showEventDetail(eventId)
+from event-modal.js. The partial provides only the modal shell — the
+actual content is rendered by JS into #event-detail-modal-content.
+
+Usage:
+ {% include 'partials/event_detail_modal.html' %}
+
+#}
+
+
+
+
+
Event Detail
+
+
+
+
+
+
+
+
+
diff --git a/templates/sfm.html b/templates/sfm.html
index 770eacb..68d4558 100644
--- a/templates/sfm.html
+++ b/templates/sfm.html
@@ -115,21 +115,9 @@
-
-
-
-
-
-
Event Detail
-
-
-
-
-
-
-
-
-
+{# Shared event-detail modal — rendered by /static/event-modal.js #}
+{% include 'partials/event_detail_modal.html' %}
+