Siddharth Ravikumar
feat: re-enable Run AI Analytics for user cases in TraceSceneFinal, keeping reference cases disabled
a023831
/**
* TraceScene β€” Frontend Application
*
* Single-page application for accident case management,
* photo upload, AI analysis, and report viewing.
*/
const API_BASE = '/api';
// ── State ─────────────────────────────────────────────────────────────
let currentView = 'landing';
let currentCaseId = null;
let currentCaseData = null; // Store full case data for editing
let selectedFiles = [];
let additionalFiles = []; // For add photos modal
// ── Init ──────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
initNavigation();
initDropZone();
initButtons();
initAccordions();
loadHealth();
loadCases();
});
// ── Accordions ────────────────────────────────────────────────────────
function initAccordions() {
document.querySelectorAll('.accordion-header').forEach(btn => {
btn.addEventListener('click', () => {
const item = btn.closest('.accordion-item');
const isActive = item.classList.contains('active');
// Close all other accordions
document.querySelectorAll('.accordion-item').forEach(i => {
i.classList.remove('active');
i.querySelector('.accordion-header').setAttribute('aria-expanded', 'false');
});
// Toggle current
if (!isActive) {
item.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
}
});
});
}
// ── Navigation ────────────────────────────────────────────────────────
function initNavigation() {
document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
switchView(view);
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
}
function switchView(viewName) {
document.querySelectorAll('.view').forEach(v => {
v.classList.remove('active');
v.classList.add('hidden');
});
const target = document.getElementById(`view-${viewName}`);
if (target) {
target.classList.remove('hidden');
target.classList.add('active');
}
currentView = viewName;
if (viewName === 'dashboard') loadCases();
if (viewName === 'rules') loadRules();
}
// ── Buttons ───────────────────────────────────────────────────────────
function initButtons() {
// Dashboard
document.getElementById('btn-refresh-cases')?.addEventListener('click', loadCases);
document.getElementById('btn-empty-new-case')?.addEventListener('click', () => {
switchView('new-case');
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
document.querySelector('[data-view="new-case"]')?.classList.add('active');
});
// New Case
document.getElementById('btn-create-case')?.addEventListener('click', createCase);
// Case Detail
document.getElementById('btn-back-dashboard')?.addEventListener('click', () => switchView('dashboard'));
document.getElementById('btn-run-analysis')?.addEventListener('click', runAnalysis);
document.getElementById('btn-view-report')?.addEventListener('click', viewReport);
document.getElementById('btn-edit-case')?.addEventListener('click', openEditModal);
document.getElementById('btn-add-photos-inline')?.addEventListener('click', openAddPhotosModal);
// Edit Modal
document.getElementById('btn-close-edit-modal')?.addEventListener('click', closeEditModal);
document.getElementById('btn-cancel-edit')?.addEventListener('click', closeEditModal);
document.getElementById('btn-save-case')?.addEventListener('click', saveCaseChanges);
// Photos Modal
document.getElementById('btn-close-photos-modal')?.addEventListener('click', closePhotosModal);
document.getElementById('btn-cancel-photos')?.addEventListener('click', closePhotosModal);
document.getElementById('btn-upload-more')?.addEventListener('click', uploadMorePhotos);
// Form logic
document.getElementById('case-number')?.addEventListener('input', validateForm);
initEditDropZone();
}
function initEditDropZone() {
const dropZone = document.getElementById('edit-drop-zone');
const fileInput = document.getElementById('edit-file-input');
if (!dropZone || !fileInput) return;
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
handleAdditionalFiles(Array.from(e.dataTransfer.files));
});
fileInput.addEventListener('change', () => {
handleAdditionalFiles(Array.from(fileInput.files));
});
}
function validateForm() {
const caseNum = document.getElementById('case-number')?.value.trim();
const btn = document.getElementById('btn-create-case');
if (btn) btn.disabled = !caseNum;
}
// ── Health ─────────────────────────────────────────────────────────────
async function loadHealth() {
try {
const resp = await fetch(`${API_BASE}/health`);
const data = await resp.json();
document.getElementById('status-model').textContent = data.model_loaded ? 'βœ“ Ready' : 'βœ— Not loaded';
document.getElementById('status-model').style.color = data.model_loaded ? '#22c55e' : '#ef4444';
document.getElementById('status-device').textContent = data.device || 'β€”';
document.getElementById('status-rules').textContent = `${data.rules_loaded} rules`;
} catch {
document.getElementById('status-model').textContent = 'βœ— Offline';
document.getElementById('status-model').style.color = '#ef4444';
}
}
// ── Cases ─────────────────────────────────────────────────────────────
async function loadCases() {
try {
const resp = await fetch(`${API_BASE}/cases`);
const data = await resp.json();
renderCases(data.cases || []);
} catch (e) {
showToast('Failed to load cases', 'error');
}
}
function renderCases(cases) {
const grid = document.getElementById('cases-grid');
const empty = document.getElementById('empty-state-dashboard');
if (!cases.length) {
grid.innerHTML = '';
grid.style.display = 'none';
empty.style.display = 'flex';
return;
}
grid.style.display = 'grid';
empty.style.display = 'none';
const referenceCases = cases.filter(c => c.is_reference);
const userCases = cases.filter(c => !c.is_reference);
let html = '';
if (referenceCases.length) {
html += `<h3 class="grid-header">Reference cases</h3>`;
html += referenceCases.map(c => renderCaseCard(c)).join('');
}
if (userCases.length) {
html += `<h3 class="grid-header" style="margin-top: 1.5rem;">My cases</h3>`;
html += userCases.map(c => renderCaseCard(c)).join('');
}
grid.innerHTML = html;
}
function renderCaseCard(c) {
return `
<div class="case-card ${c.is_reference ? 'reference-card' : ''}" onclick="openCase(${c.id})">
${!c.is_reference ? `
<button class="btn-delete-card" onclick="event.stopPropagation(); deleteCase(${c.id}, '${escHtml(c.case_number)}')" title="Delete Case">
<i class="fa-solid fa-trash-can"></i>
</button>` : ''}
<div class="card-header">
<span class="case-number">${escHtml(c.case_number)}</span>
<span class="status-badge ${c.status}">
<i class="fa-solid fa-circle"></i> ${c.status}
</span>
</div>
<div class="case-meta">
${c.officer_name ? `<span><i class="fa-solid fa-user-shield"></i> ${escHtml(c.officer_name)}</span>` : ''}
${c.location ? `<span><i class="fa-solid fa-location-dot"></i> ${escHtml(c.location)}</span>` : ''}
${c.incident_date ? `<span><i class="fa-solid fa-calendar"></i> ${c.incident_date}</span>` : ''}
</div>
<div class="card-footer">
<span class="photo-count"><i class="fa-solid fa-camera"></i> ${c.photo_count || 0} photos</span>
<span style="font-size:0.72rem;color:var(--text-muted)">${c.is_reference ? 'Static Reference' : formatDate(c.created_at)}</span>
</div>
</div>
`;
}
async function deleteCase(id, caseNum) {
if (!confirm(`Are you sure you want to delete Case ${caseNum}? This will permanently remove all photos and analysis data.`)) {
return;
}
try {
const resp = await fetch(`${API_BASE}/cases/${id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete failed');
showToast(`Case ${caseNum} deleted`, 'success');
loadCases();
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Create Case ───────────────────────────────────────────────────────
async function createCase() {
const caseNumber = document.getElementById('case-number').value.trim();
if (!caseNumber) return showToast('Case number is required', 'error');
const formData = new FormData();
formData.append('case_number', caseNumber);
formData.append('officer_name', document.getElementById('officer-name').value.trim());
formData.append('location', document.getElementById('incident-location').value.trim());
formData.append('incident_date', document.getElementById('incident-date').value);
formData.append('notes', document.getElementById('officer-notes').value.trim());
try {
const resp = await fetch(`${API_BASE}/cases`, { method: 'POST', body: formData });
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Failed to create case');
}
const data = await resp.json();
const caseId = data.id;
showToast('Case created', 'success');
// Upload photos if selected
if (selectedFiles.length > 0) {
await uploadPhotos(caseId);
}
// Clear form
document.getElementById('case-number').value = '';
document.getElementById('officer-name').value = '';
document.getElementById('incident-location').value = '';
document.getElementById('incident-date').value = '';
document.getElementById('officer-notes').value = '';
selectedFiles = [];
document.getElementById('photo-preview').classList.add('hidden');
// Open the case detail
openCase(caseId);
} catch (e) {
showToast(e.message, 'error');
}
}
async function uploadPhotos(caseId) {
const progressSection = document.getElementById('upload-progress');
const progressFill = document.getElementById('upload-progress-fill');
const statusText = document.getElementById('upload-status-text');
progressSection.classList.remove('hidden');
statusText.textContent = `Uploading ${selectedFiles.length} photos...`;
const formData = new FormData();
selectedFiles.forEach(f => formData.append('files', f));
try {
progressFill.style.width = '50%';
const resp = await fetch(`${API_BASE}/cases/${caseId}/photos`, {
method: 'POST',
body: formData,
});
if (!resp.ok) throw new Error('Upload failed');
const data = await resp.json();
progressFill.style.width = '100%';
statusText.textContent = `${data.count} photos uploaded βœ“`;
showToast(`${data.count} photos uploaded`, 'success');
return data;
} catch (e) {
statusText.textContent = 'Upload failed';
showToast('Photo upload failed', 'error');
throw e;
}
}
// ── Drop Zone ─────────────────────────────────────────────────────────
function initDropZone() {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
if (!dropZone || !fileInput) return;
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
handleFiles(Array.from(e.dataTransfer.files));
});
fileInput.addEventListener('change', () => {
handleFiles(Array.from(fileInput.files));
});
}
function handleFiles(files) {
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (!imageFiles.length) return showToast('No image files found', 'error');
selectedFiles = imageFiles.slice(0, 20); // Max 20
// Show previews
const preview = document.getElementById('photo-preview');
const thumbs = document.getElementById('photo-thumbnails');
const count = document.getElementById('photo-count');
preview.classList.remove('hidden');
count.textContent = `${selectedFiles.length} photo${selectedFiles.length > 1 ? 's' : ''} selected`;
thumbs.innerHTML = '';
selectedFiles.forEach(file => {
const div = document.createElement('div');
div.className = 'photo-thumbnail';
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
div.appendChild(img);
thumbs.appendChild(div);
});
validateForm();
}
// ── Open Case Detail ──────────────────────────────────────────────────
async function openCase(caseId) {
currentCaseId = caseId;
switchView('case-detail');
try {
const resp = await fetch(`${API_BASE}/cases/${caseId}`);
const data = await resp.json();
currentCaseData = data.case;
renderCaseDetail(data);
} catch (e) {
showToast('Failed to load case', 'error');
}
}
// ── Edit Case ─────────────────────────────────────────────────────────
function openEditModal() {
if (!currentCaseData) return;
document.getElementById('edit-officer-name').value = currentCaseData.officer_name || '';
document.getElementById('edit-incident-location').value = currentCaseData.location || '';
document.getElementById('edit-incident-date').value = currentCaseData.incident_date || '';
document.getElementById('edit-officer-notes').value = currentCaseData.notes || '';
document.getElementById('modal-edit-case').classList.remove('hidden');
}
function closeEditModal() {
document.getElementById('modal-edit-case').classList.add('hidden');
}
async function saveCaseChanges() {
if (!currentCaseId) return;
const formData = new FormData();
formData.append('officer_name', document.getElementById('edit-officer-name').value.trim());
formData.append('location', document.getElementById('edit-incident-location').value.trim());
formData.append('incident_date', document.getElementById('edit-incident-date').value);
formData.append('notes', document.getElementById('edit-officer-notes').value.trim());
try {
const resp = await fetch(`${API_BASE}/cases/${currentCaseId}`, {
method: 'PUT',
body: formData
});
if (!resp.ok) throw new Error('Failed to update case');
showToast('Case updated', 'success');
closeEditModal();
openCase(currentCaseId); // Refresh
} catch (e) {
showToast(e.message, 'error');
}
}
// ── Add Photos ────────────────────────────────────────────────────────
function openAddPhotosModal() {
additionalFiles = [];
document.getElementById('edit-photo-preview').classList.add('hidden');
document.getElementById('edit-upload-progress').classList.add('hidden');
document.getElementById('btn-upload-more').disabled = true;
document.getElementById('modal-add-photos').classList.remove('hidden');
}
function closePhotosModal() {
document.getElementById('modal-add-photos').classList.add('hidden');
}
function handleAdditionalFiles(files) {
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (!imageFiles.length) return showToast('No image files found', 'error');
additionalFiles = imageFiles.slice(0, 20);
const preview = document.getElementById('edit-photo-preview');
const thumbs = document.getElementById('edit-photo-thumbnails');
const count = document.getElementById('edit-photo-count');
preview.classList.remove('hidden');
count.textContent = `${additionalFiles.length} photo${additionalFiles.length > 1 ? 's' : ''} selected`;
thumbs.innerHTML = '';
additionalFiles.forEach(file => {
const div = document.createElement('div');
div.className = 'photo-thumbnail';
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
div.appendChild(img);
thumbs.appendChild(div);
});
document.getElementById('btn-upload-more').disabled = additionalFiles.length === 0;
}
async function uploadMorePhotos() {
if (!currentCaseId || !additionalFiles.length) return;
const progressSection = document.getElementById('edit-upload-progress');
const progressFill = document.getElementById('edit-upload-progress-fill');
const statusText = document.getElementById('edit-upload-status-text');
progressSection.classList.remove('hidden');
document.getElementById('btn-upload-more').disabled = true;
const formData = new FormData();
additionalFiles.forEach(f => formData.append('files', f));
try {
progressFill.style.width = '50%';
const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/photos`, {
method: 'POST',
body: formData,
});
if (!resp.ok) throw new Error('Upload failed');
progressFill.style.width = '100%';
statusText.textContent = `Upload complete βœ“`;
showToast('Photos added successfully', 'success');
setTimeout(() => {
closePhotosModal();
openCase(currentCaseId); // Refresh
}, 1000);
} catch (e) {
statusText.textContent = 'Upload failed';
showToast('Photo upload failed', 'error');
document.getElementById('btn-upload-more').disabled = false;
}
}
function renderCaseDetail(data) {
const c = data.case;
// Header
document.getElementById('case-detail-title').textContent = `Case: ${c.case_number}`;
// Info bar
document.getElementById('detail-case-number').textContent = c.case_number;
document.getElementById('detail-officer').textContent = c.officer_name || 'N/A';
document.getElementById('detail-location').textContent = c.location || 'N/A';
document.getElementById('detail-date').textContent = c.incident_date || 'N/A';
document.getElementById('detail-status').textContent = c.status;
const statusChip = document.getElementById('detail-status-chip');
statusChip.className = `info-chip status-chip ${c.status}`;
// Enable/disable buttons
const btnAnalysis = document.getElementById('btn-run-analysis');
const btnReport = document.getElementById('btn-view-report');
const btnEdit = document.getElementById('btn-edit-case');
const btnAddPhotos = document.getElementById('btn-add-photos-inline');
if (c.is_reference) {
btnAnalysis.disabled = true;
btnAnalysis.title = "AI Analytics is disabled for reference cases";
if (btnEdit) btnEdit.disabled = true;
if (btnAddPhotos) btnAddPhotos.disabled = true;
} else {
btnAnalysis.disabled = !data.photos?.length;
btnAnalysis.title = data.photos?.length ? "" : "Upload photos to run analysis";
if (btnEdit) btnEdit.disabled = false;
if (btnAddPhotos) btnAddPhotos.disabled = false;
}
btnReport.disabled = c.status !== 'complete';
if (data.photos?.length) {
photosGrid.innerHTML = data.photos.map(p => {
const imgSrc = `/${p.filepath}`;
return `
<div class="detail-photo">
<img src="${imgSrc}" alt="${escHtml(p.filename)}" onerror="this.onerror=null; this.src='/static/placeholder.jpg';" loading="lazy">
</div>
`;
}).join('');
} else {
photosGrid.innerHTML = '<p class="placeholder-text">No photos uploaded yet.</p>';
}
// Analyses
const analysisContent = document.getElementById('analysis-content');
if (data.analyses?.length) {
analysisContent.innerHTML = data.analyses.map(a => `
<div class="analysis-photo-section" style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border);">
<div class="analysis-photo-label" style="font-weight: 600; margin-bottom: 1rem;">πŸ“· ${escHtml(a.filename || 'Photo')}</div>
<div class="markdown-body">
${marked.parse(a.raw_analysis || '')}
</div>
</div>
`).join('');
} else {
analysisContent.innerHTML = '<p class="placeholder-text">Run analysis to see AI observations.</p>';
}
// Violations
const violationsList = document.getElementById('violations-list');
const violationBadge = document.getElementById('violation-badge');
violationBadge.textContent = data.violations?.length || 0;
if (data.violations?.length) {
violationsList.innerHTML = data.violations.map(v => `
<div class="violation-card ${v.severity || 'MEDIUM'}">
<div class="violation-header">
<span class="violation-title">${escHtml(v.rule_title)}</span>
<span class="severity-tag ${v.severity || 'MEDIUM'}">${v.severity || 'MEDIUM'}</span>
</div>
<div class="violation-meta">
<span><i class="fa-solid fa-hashtag"></i> ${v.rule_id}</span>
<span><i class="fa-solid fa-percent"></i> ${Math.round(v.confidence * 100)}% confidence</span>
${v.party_label ? `<span><i class="fa-solid fa-car"></i> ${escHtml(v.party_label)}</span>` : ''}
</div>
${v.evidence_summary ? `<div class="violation-evidence">${escHtml(v.evidence_summary)}</div>` : ''}
</div>
`).join('');
} else {
violationsList.innerHTML = '<p class="placeholder-text">No violations detected yet.</p>';
}
// Fault Analysis
renderFaultAnalysis(data.fault_analysis, data.parties);
}
function renderFaultAnalysis(fault, parties) {
const content = document.getElementById('fault-content');
if (!fault) {
content.innerHTML = '<p class="placeholder-text">Run analysis to determine fault.</p>';
return;
}
let html = '';
// Fault distribution bars
if (fault.fault_distribution_json) {
let dist = {};
try {
dist = typeof fault.fault_distribution_json === 'string'
? JSON.parse(fault.fault_distribution_json)
: fault.fault_distribution_json;
} catch { dist = {}; }
const entries = Object.entries(dist);
if (entries.length) {
html += '<div class="fault-party-bars">';
entries.forEach(([label, pct], i) => {
html += `
<div class="party-bar">
<div class="party-bar-label">
<span class="party-name">${escHtml(label)}</span>
<span class="party-pct">${pct}%</span>
</div>
<div class="party-bar-track">
<div class="party-bar-fill ${i === 0 ? 'primary' : 'secondary'}"
style="width: ${pct}%"></div>
</div>
</div>
`;
});
html += '</div>';
}
}
// Probable cause
if (fault.probable_cause) {
html += `
<div class="fault-cause">
<h4><i class="fa-solid fa-magnifying-glass-chart"></i> Probable Cause</h4>
<p>${escHtml(fault.probable_cause)}</p>
</div>
`;
}
// Confidence
if (fault.overall_confidence != null) {
const pct = Math.round(fault.overall_confidence * 100);
html += `
<div class="confidence-meter">
<span>Confidence:</span>
<div class="meter-bar">
<div class="meter-fill" style="width: ${pct}%"></div>
</div>
<span>${pct}%</span>
</div>
`;
}
// Summary
if (fault.analysis_summary) {
html += `<p style="margin-top:0.8rem;font-size:0.82rem;color:var(--text-secondary)">
${escHtml(fault.analysis_summary)}</p>`;
}
content.innerHTML = html || '<p class="placeholder-text">No fault analysis available.</p>';
}
// ── Run Analysis ──────────────────────────────────────────────────────
// ── Report ────────────────────────────────────────────────────────────
async function viewReport() {
if (!currentCaseId) return;
switchView('report');
try {
const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/report`);
const report = await resp.json();
renderReport(report);
} catch (e) {
showToast('Failed to load report', 'error');
}
}
function renderReport(report) {
const content = document.getElementById('report-content');
let html = `
<div class="report-header">
<h2><i class="fa-solid fa-shield-halved"></i> ${report.report_type || 'Incident Report'}</h2>
<p class="report-subtitle">Case ${escHtml(report.case?.case_number || '')} β€’ Generated by AI</p>
</div>
<div class="report-disclaimer">
<i class="fa-solid fa-triangle-exclamation"></i> ${escHtml(report.disclaimer || '')}
</div>
`;
// Stats
const stats = report.statistics || {};
html += `
<div class="report-stat-grid">
<div class="report-stat">
<div class="stat-number">${stats.total_photos || 0}</div>
<div class="stat-label">Photos</div>
</div>
<div class="report-stat">
<div class="stat-number">${stats.total_violations || 0}</div>
<div class="stat-label">Violations</div>
</div>
<div class="report-stat">
<div class="stat-number">${stats.critical_violations || 0}</div>
<div class="stat-label">Critical</div>
</div>
<div class="report-stat">
<div class="stat-number">${stats.parties_identified || 0}</div>
<div class="stat-label">Parties</div>
</div>
</div>
`;
// Case Info
html += `<h3>Case Information</h3>`;
html += `<p><strong>Case Number:</strong> ${escHtml(report.case?.case_number || 'N/A')}</p>`;
html += `<p><strong>Officer:</strong> ${escHtml(report.case?.officer_name || 'N/A')}</p>`;
html += `<p><strong>Location:</strong> ${escHtml(report.case?.location || 'N/A')}</p>`;
html += `<p><strong>Date:</strong> ${report.case?.incident_date || 'N/A'}</p>`;
if (report.case?.notes) html += `<p><strong>Notes:</strong> ${escHtml(report.case.notes)}</p>`;
// Scene Summary
if (report.scene_summary) {
html += `<h3>Scene Analysis</h3>`;
html += `<p style="white-space:pre-wrap">${escHtml(report.scene_summary)}</p>`;
}
// Parties
if (report.parties?.length) {
html += `<h3>Parties Involved</h3>`;
report.parties.forEach(p => {
html += `<h4>${escHtml(p.label)}</h4>`;
html += `<p>Type: ${p.vehicle_type || 'Unknown'} β€’ Color: ${p.vehicle_color || 'Unknown'}</p>`;
});
}
// Violations
if (report.violations?.list?.length) {
html += `<h3>Traffic Violations Detected</h3>`;
report.violations.list.forEach(v => {
html += `
<div class="violation-card ${v.severity}" style="margin-bottom:0.5rem">
<div class="violation-header">
<span class="violation-title">${escHtml(v.title)}</span>
<span class="severity-tag ${v.severity}">${v.severity}</span>
</div>
<p style="font-size:0.82rem;color:var(--text-secondary);margin-top:0.3rem">
Party: ${escHtml(v.party)} β€’ Confidence: ${Math.round(v.confidence * 100)}%
</p>
${v.evidence ? `<p style="font-size:0.78rem;color:var(--text-muted);font-style:italic">${escHtml(v.evidence)}</p>` : ''}
</div>
`;
});
}
// Fault Analysis
if (report.fault_analysis?.determined) {
html += `<h3>Fault Analysis</h3>`;
html += `<p><strong>Primary Fault:</strong> ${escHtml(report.fault_analysis.primary_fault_party || 'Undetermined')}</p>`;
if (report.fault_analysis.fault_distribution) {
html += `<p><strong>Distribution:</strong> `;
html += Object.entries(report.fault_analysis.fault_distribution)
.map(([k, v]) => `${k}: ${v}%`).join(' β€’ ');
html += `</p>`;
}
html += `<p><strong>Confidence:</strong> ${Math.round((report.fault_analysis.overall_confidence || 0) * 100)}%</p>`;
if (report.fault_analysis.probable_cause) {
html += `<h4>Probable Cause</h4>`;
html += `<p>${escHtml(report.fault_analysis.probable_cause)}</p>`;
}
}
content.innerHTML = html;
}
// ── Rules ─────────────────────────────────────────────────────────────
async function loadRules() {
try {
const resp = await fetch(`${API_BASE}/rules`);
const data = await resp.json();
renderRules(data);
} catch (e) {
showToast('Failed to load rules', 'error');
}
}
function renderRules(data) {
const content = document.getElementById('rules-content');
if (!data.categories?.length) {
content.innerHTML = '<p class="placeholder-text">No rules loaded.</p>';
return;
}
content.innerHTML = data.categories.map(cat => `
<div class="rule-category">
<div class="rule-category-header">
<i class="fa-solid fa-gavel"></i>
${escHtml(cat.name)}
<span class="rule-count">${cat.rule_count} rules</span>
</div>
<div class="rule-list">
${cat.rules.map(r => `
<div class="rule-item">
<span class="rule-id">${r.id}</span>
<span>${escHtml(r.title)}</span>
<span class="severity-tag ${r.severity}" style="margin-left:auto">${r.severity}</span>
</div>
`).join('')}
</div>
</div>
`).join('');
}
// ── Toast ─────────────────────────────────────────────────────────────
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// ── Helpers ───────────────────────────────────────────────────────────
function escHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch { return dateStr; }
}