feat(sfm): unified deployment timeline (deprecate deployment_records)
Phase 4. Rebuilds the seismograph "Deployment History" + "Timeline"
sections on the unit detail page as a single derived view computed from
three sources: unit_assignments (authoritative project/location windows),
unit_history (calibration/retirement/deployed state changes), and SFM
events overlaid per assignment window (count + peak PVS + last event).
Fixes the wonky-timeline symptoms: missing entries, duplicate/contradictory
rows, and no visibility into what the unit was actually doing during each
deployment window.
Backend:
- backend/services/deployment_timeline.py: new deployment_timeline_for_unit()
helper. Merges UnitAssignment rows (with SFM event overlay fetched
concurrently via httpx), UnitHistory state-change rows (filtered to
meaningful change_types and de-noised by dropping rows where
old_value == new_value — there's noise in legacy audit log from
record_history() being called on every save), and synthetic "gap"
entries between assignments >= 1 day apart. Sorts newest first.
- backend/routers/units.py: new GET /api/units/{unit_id}/deployment_timeline
endpoint with optional include_events=false flag.
- backend/routers/project_locations.py: assign / unassign / swap /
update endpoints now write UnitHistory rows on every assignment
lifecycle event. New change_types: assignment_created,
assignment_ended, assignment_swapped, assignment_updated. These
surface in the unified timeline (where the assignment row itself
shows the structural data; the audit row is filtered out to avoid
double-rendering). Closes a real gap — assignment changes were
previously invisible to any audit consumer.
- backend/migrate_deprecate_deployment_records.py: non-destructive
migration. Adds deployment_records.deprecated_at column. For each
legacy row without a matching UnitAssignment, best-effort
synthesizes one (with the free-text location_name preserved in
notes). Marks every processed row. Idempotent. DROP TABLE
deferred to a follow-up release.
Frontend (templates/unit_detail.html):
- Removed legacy "Deployment History" card (with Log Deployment button)
and the separate "Timeline" card. Replaced with a single
"Deployment Timeline" section.
- Three entry visual styles: assignment rows (orange dot, location +
project link, event-overlay summary), gap rows (dashed outline, idle
day count), and state_change rows (navy dot, friendly label, old →
new value). Active assignments get a green dot + "active" badge.
- Existing loadUnitHistory() and loadDeploymentHistory() functions kept
as shims that delegate to loadDeploymentTimeline(), so modal-save
callbacks that referenced them still trigger a refresh of the visible
section. Legacy function bodies preserved under _legacy_*_unused
names for archeology; not called by anything.
Verified end-to-end:
- BE11529 timeline now shows 2 entries (active assignment with 24-event
overlay + the deployed→benched state change), compared to the previous
noisy mix that included 6 no-op state-change rows.
- Migration ran against real DB: 1 legacy row processed (had no
project_id, marked deprecated without backfill).
- Assign / unassign / swap / edit now leave a paper trail in
unit_history.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+175
-20
@@ -278,19 +278,17 @@
|
||||
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||
</div>
|
||||
|
||||
<!-- Deployment History -->
|
||||
<!-- Deployment Timeline (Phase 4 unified view — derived from
|
||||
unit_assignments + unit_history + SFM event overlay) -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment History</h3>
|
||||
<button onclick="openNewDeploymentModal()" class="px-3 py-1.5 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm transition-colors flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Log Deployment
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment Timeline</h3>
|
||||
<button onclick="loadDeploymentTimeline()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
|
||||
↻ Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="deploymentHistory" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
<div id="deploymentTimeline" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -379,13 +377,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit History Timeline -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
||||
<div id="historyTimeline" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
@@ -1632,8 +1623,15 @@ async function uploadPhoto(file) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load and display unit history timeline
|
||||
// Legacy timeline loader — Phase 4 unified the timeline view. Now a shim
|
||||
// that delegates to loadDeploymentTimeline() so existing callers from modal
|
||||
// save handlers still trigger a refresh of the visible section.
|
||||
async function loadUnitHistory() {
|
||||
if (typeof loadDeploymentTimeline === 'function') {
|
||||
return loadDeploymentTimeline();
|
||||
}
|
||||
}
|
||||
async function _legacy_loadUnitHistory_unused() {
|
||||
try {
|
||||
const response = await fetch(`/api/roster/history/${unitId}`);
|
||||
if (!response.ok) {
|
||||
@@ -1805,10 +1803,18 @@ async function pingModem() {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Deployment History
|
||||
// Deployment History (legacy — Phase 4 superseded by deployment_timeline)
|
||||
// ============================================================
|
||||
|
||||
// Phase 4 shim: delegate to the unified timeline loader so existing modal
|
||||
// save handlers (legacy "Log Deployment" form, edit-save callbacks) still
|
||||
// trigger a refresh of the visible Deployment Timeline section.
|
||||
async function loadDeploymentHistory() {
|
||||
if (typeof loadDeploymentTimeline === 'function') {
|
||||
return loadDeploymentTimeline();
|
||||
}
|
||||
}
|
||||
async function _legacy_loadDeploymentHistory_unused() {
|
||||
try {
|
||||
const res = await fetch(`/api/deployments/${unitId}`);
|
||||
const data = await res.json();
|
||||
@@ -1969,14 +1975,163 @@ loadCalibrationInterval();
|
||||
setupCalibrationAutoCalc();
|
||||
loadUnitData().then(() => {
|
||||
loadPhotos();
|
||||
loadUnitHistory();
|
||||
loadDeploymentHistory();
|
||||
loadDeploymentTimeline();
|
||||
if (currentUnit && currentUnit.device_type === 'seismograph') {
|
||||
document.getElementById('sfmEventsSection').classList.remove('hidden');
|
||||
loadUnitEvents();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Unified Deployment Timeline (Phase 4) ────────────────────────────────────
|
||||
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
||||
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
||||
|
||||
async function loadDeploymentTimeline() {
|
||||
const container = document.getElementById('deploymentTimeline');
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
renderDeploymentTimeline(d.entries || [], container);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _dtFmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
function _dtFmtDateTime(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso.slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
|
||||
function _dtEsc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function _dtPpvClass(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 _dtRenderAssignment(e) {
|
||||
const start = _dtFmtDate(e.starts_at);
|
||||
const end = e.is_active ? 'present' : _dtFmtDate(e.ends_at);
|
||||
const dur = (e.duration_days != null)
|
||||
? `<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">(${e.duration_days.toFixed(1)} day${e.duration_days === 1 ? '' : 's'})</span>`
|
||||
: '';
|
||||
const ov = e.event_overlay || {};
|
||||
const evCount = ov.event_count ?? 0;
|
||||
const peak = ov.peak_pvs;
|
||||
|
||||
const locLink = e.location_id
|
||||
? `<a href="/projects/${_dtEsc(e.project_id)}/nrl/${_dtEsc(e.location_id)}" class="text-seismo-orange hover:text-seismo-navy font-medium">📍 ${_dtEsc(e.location_name || 'unnamed location')}</a>`
|
||||
: `<span class="text-gray-500 dark:text-gray-400 italic">📍 (no location FK — synthesized from legacy deployment_records)</span>`;
|
||||
|
||||
const projLine = e.project_name
|
||||
? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${_dtEsc(e.project_name)}</div>`
|
||||
: '';
|
||||
|
||||
const activeBadge = e.is_active
|
||||
? '<span class="px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
|
||||
: '';
|
||||
|
||||
const overlay = evCount > 0
|
||||
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
|
||||
${peak != null ? `<span>peak <strong class="${_dtPpvClass(peak)}">${peak.toFixed(4)} in/s</strong></span>` : ''}
|
||||
${ov.last_event ? `<span>last ${_dtFmtDateTime(ov.last_event)}</span>` : ''}
|
||||
</div>`
|
||||
: `<div class="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">No events recorded during this window.</div>`;
|
||||
|
||||
const notes = e.notes
|
||||
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
||||
: '';
|
||||
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
||||
</div>
|
||||
${activeBadge}
|
||||
</div>
|
||||
<div class="mt-1">${locLink}</div>
|
||||
${projLine}
|
||||
${overlay}
|
||||
${notes}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _dtRenderGap(e) {
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full border-2 border-gray-400 dark:border-gray-500"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50/40 dark:bg-slate-900/20 rounded-lg p-3 border border-dashed border-gray-300 dark:border-gray-700">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>${_dtFmtDate(e.starts_at)}</strong> → <strong>${_dtFmtDate(e.ends_at)}</strong>
|
||||
<span class="text-xs ml-2">(${e.duration_days.toFixed(1)} day${e.duration_days === 1 ? '' : 's'} idle)</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No active assignment</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _dtRenderStateChange(e) {
|
||||
// Friendly labels for known change_types.
|
||||
const labels = {
|
||||
deployed_change: 'Deployed status changed',
|
||||
retired_change: 'Retired status changed',
|
||||
calibration_status_change: 'Calibration status changed',
|
||||
last_calibrated_change: 'Last calibrated updated',
|
||||
next_calibration_due_change: 'Next calibration due updated',
|
||||
allocation_change: 'Allocation changed',
|
||||
};
|
||||
const label = labels[e.change_type] || e.change_type;
|
||||
|
||||
return `<div class="flex gap-3">
|
||||
<div class="flex flex-col items-center pt-1">
|
||||
<span class="w-3 h-3 rounded-full bg-seismo-navy"></span>
|
||||
</div>
|
||||
<div class="flex-1 bg-gray-50 dark:bg-slate-900/30 rounded-lg p-3">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
📅 <strong>${_dtFmtDateTime(e.starts_at)}</strong> — ${_dtEsc(label)}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
${_dtEsc(e.old_value || '—')} → <strong>${_dtEsc(e.new_value || '—')}</strong>
|
||||
</div>
|
||||
${e.history_notes ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">${_dtEsc(e.history_notes)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDeploymentTimeline(entries, container) {
|
||||
if (!entries.length) {
|
||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment history yet. Assign this unit to a project location to start a deployment record.</p>';
|
||||
return;
|
||||
}
|
||||
const html = entries.map(e => {
|
||||
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
||||
if (e.kind === 'gap') return _dtRenderGap(e);
|
||||
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
||||
return '';
|
||||
}).join('');
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── SFM Events section ──────────────────────────────────────────────────────
|
||||
function clearUnitEventFilters() {
|
||||
document.getElementById('ue-filter-bucket').value = 'all';
|
||||
|
||||
Reference in New Issue
Block a user