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:
2026-05-12 00:15:07 +00:00
parent 63bd6ad8a2
commit f1f3da8e61
5 changed files with 760 additions and 20 deletions
+175 -20
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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';