v0.11.0 #50
@@ -916,6 +916,142 @@ async def delete_assignment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/assignments/merge")
|
||||||
|
async def merge_assignments(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Merge multiple consecutive UnitAssignment rows for the same (unit, location)
|
||||||
|
into a single record spanning their combined window.
|
||||||
|
|
||||||
|
Use case: a unit's deployment timeline shows 3 stacked rows for the
|
||||||
|
same location because the assignment was closed-and-reopened (e.g. via
|
||||||
|
location remove + restore) or because metadata-backfill auto-created
|
||||||
|
a retroactive window adjacent to a manual one. Operator sees three
|
||||||
|
rows but they represent one continuous deployment.
|
||||||
|
|
||||||
|
Body JSON:
|
||||||
|
{ "assignment_ids": ["<uuid>", "<uuid>", ...] }
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
- All assignments must belong to project_id
|
||||||
|
- All must share the same unit_id AND location_id
|
||||||
|
- At least 2 ids must be provided
|
||||||
|
|
||||||
|
Merge rules:
|
||||||
|
- Keeps the EARLIEST-starting assignment as the surviving row
|
||||||
|
- assigned_at = min(assigned_at across all)
|
||||||
|
- assigned_until = max(assigned_until), or NULL if any input was active
|
||||||
|
- status = "active" if any input was active, else "completed"
|
||||||
|
- source = source of the earliest record (preserves original ingest provenance)
|
||||||
|
- notes = earliest's notes + "Merged N records (<sources>)"
|
||||||
|
- Other records are DELETED
|
||||||
|
- One UnitHistory `assignment_merged` row is written for audit
|
||||||
|
|
||||||
|
No tolerance check — the operator is asking for the merge, so we trust
|
||||||
|
the intent. The UI can pre-filter to only offer "consecutive" merges
|
||||||
|
if it wants.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
ids = payload.get("assignment_ids") or []
|
||||||
|
if not isinstance(ids, list) or len(ids) < 2:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Need at least 2 assignment_ids to merge.",
|
||||||
|
)
|
||||||
|
|
||||||
|
assignments = (
|
||||||
|
db.query(UnitAssignment)
|
||||||
|
.filter(UnitAssignment.project_id == project_id)
|
||||||
|
.filter(UnitAssignment.id.in_(ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(assignments) != len(set(ids)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Some assignments not found (got {len(assignments)} of {len(set(ids))}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
unit_ids = {a.unit_id for a in assignments}
|
||||||
|
loc_ids = {a.location_id for a in assignments}
|
||||||
|
if len(unit_ids) > 1 or len(loc_ids) > 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Can only merge assignments that share the same unit and location.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order chronologically.
|
||||||
|
assignments.sort(key=lambda a: a.assigned_at)
|
||||||
|
earliest = assignments[0]
|
||||||
|
others = assignments[1:]
|
||||||
|
|
||||||
|
# Compute merged window.
|
||||||
|
any_active = any(a.assigned_until is None for a in assignments)
|
||||||
|
if any_active:
|
||||||
|
merged_until = None
|
||||||
|
else:
|
||||||
|
merged_until = max(a.assigned_until for a in assignments)
|
||||||
|
|
||||||
|
# Build a brief audit-style note describing what got merged.
|
||||||
|
bits = []
|
||||||
|
for a in assignments:
|
||||||
|
win = (
|
||||||
|
f"{a.assigned_at:%Y-%m-%d}"
|
||||||
|
f"→{(a.assigned_until and a.assigned_until.strftime('%Y-%m-%d')) or 'active'}"
|
||||||
|
)
|
||||||
|
bits.append(f"{win} [{a.source}]")
|
||||||
|
merge_note_suffix = f"Merged {len(assignments)} records: " + " + ".join(bits)
|
||||||
|
new_notes = (earliest.notes + " • " + merge_note_suffix) if earliest.notes else merge_note_suffix
|
||||||
|
|
||||||
|
# Resolve names for the audit log before mutating.
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=earliest.location_id).first()
|
||||||
|
location_label = location.name if location else earliest.location_id
|
||||||
|
|
||||||
|
# Mutate the survivor.
|
||||||
|
earliest.assigned_at = min(a.assigned_at for a in assignments)
|
||||||
|
earliest.assigned_until = merged_until
|
||||||
|
earliest.status = "active" if any_active else "completed"
|
||||||
|
earliest.notes = new_notes
|
||||||
|
|
||||||
|
# Delete the rest.
|
||||||
|
deleted_ids = [a.id for a in others]
|
||||||
|
for a in others:
|
||||||
|
db.delete(a)
|
||||||
|
|
||||||
|
_record_assignment_history(
|
||||||
|
db,
|
||||||
|
unit_id=earliest.unit_id,
|
||||||
|
change_type="assignment_merged",
|
||||||
|
old_value=f"{len(assignments)} rows at {location_label}",
|
||||||
|
new_value=(
|
||||||
|
f"1 row {earliest.assigned_at:%Y-%m-%d}"
|
||||||
|
f"→{(merged_until and merged_until.strftime('%Y-%m-%d')) or 'active'}"
|
||||||
|
),
|
||||||
|
notes=merge_note_suffix,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(earliest)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Merged {len(assignments)} assignments into one.",
|
||||||
|
"kept_id": earliest.id,
|
||||||
|
"deleted_ids": deleted_ids,
|
||||||
|
"merged_window": {
|
||||||
|
"assigned_at": earliest.assigned_at.isoformat(),
|
||||||
|
"assigned_until": earliest.assigned_until.isoformat() if earliest.assigned_until else None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/locations/{location_id}/swap")
|
@router.post("/locations/{location_id}/swap")
|
||||||
async def swap_unit_on_location(
|
async def swap_unit_on_location(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ log = logging.getLogger("backend.services.deployment_timeline")
|
|||||||
# clutter from a sub-second handoff during a swap workflow.
|
# clutter from a sub-second handoff during a swap workflow.
|
||||||
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
_MIN_GAP_SECONDS = 24 * 3600 # 1 day
|
||||||
|
|
||||||
|
# When detecting "mergeable" groups of consecutive same-location assignments,
|
||||||
|
# treat assignments separated by no more than this many seconds as adjacent.
|
||||||
|
# Generous enough to catch overnight handoffs and weekend gaps where the
|
||||||
|
# operator forgot to log, but tight enough that genuinely separate
|
||||||
|
# deployments months apart don't get suggested for merging.
|
||||||
|
_MERGE_GAP_TOLERANCE_SECONDS = 7 * 24 * 3600 # 7 days
|
||||||
|
|
||||||
# Per-call timeout when querying SFM for the event overlay.
|
# Per-call timeout when querying SFM for the event overlay.
|
||||||
_SFM_TIMEOUT = 10.0
|
_SFM_TIMEOUT = 10.0
|
||||||
_SFM_FETCH_CEILING = 5000
|
_SFM_FETCH_CEILING = 5000
|
||||||
@@ -245,12 +252,41 @@ async def deployment_timeline_for_unit(
|
|||||||
"history_notes": h.notes,
|
"history_notes": h.notes,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 6. Sort newest first. Active assignments (no end) sort by start time,
|
# 6. Detect mergeable groups — runs of consecutive assignments to the
|
||||||
|
# same location with small gaps between them. Each group becomes a
|
||||||
|
# list of assignment_ids; the UI offers a "Merge into one" action
|
||||||
|
# on any group >= 2.
|
||||||
|
merge_groups: list[list[str]] = []
|
||||||
|
if len(assignments) >= 2:
|
||||||
|
# Sort ascending for the linear scan.
|
||||||
|
sorted_assignments = sorted(assignments, key=lambda a: a.assigned_at)
|
||||||
|
cur_group: list[UnitAssignment] = [sorted_assignments[0]]
|
||||||
|
for a in sorted_assignments[1:]:
|
||||||
|
prev = cur_group[-1]
|
||||||
|
same_location = a.location_id == prev.location_id
|
||||||
|
prev_end = prev.assigned_until or now
|
||||||
|
gap_seconds = (a.assigned_at - prev_end).total_seconds() if a.assigned_at else 0
|
||||||
|
# Within tolerance and same location → extend the current group.
|
||||||
|
# Negative gaps (overlap) also count as adjacent.
|
||||||
|
if same_location and gap_seconds <= _MERGE_GAP_TOLERANCE_SECONDS:
|
||||||
|
cur_group.append(a)
|
||||||
|
else:
|
||||||
|
if len(cur_group) >= 2:
|
||||||
|
merge_groups.append([x.id for x in cur_group])
|
||||||
|
cur_group = [a]
|
||||||
|
if len(cur_group) >= 2:
|
||||||
|
merge_groups.append([x.id for x in cur_group])
|
||||||
|
|
||||||
|
# 7. Sort newest first. Active assignments (no end) sort by start time,
|
||||||
# same as everything else.
|
# same as everything else.
|
||||||
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
entries.sort(key=lambda e: e.get("starts_at") or "", reverse=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"unit_id": unit.id,
|
"unit_id": unit.id,
|
||||||
"device_type": unit.device_type,
|
"device_type": unit.device_type,
|
||||||
"entries": entries,
|
"entries": entries,
|
||||||
|
# List of assignment_id lists; each inner list is a mergeable group.
|
||||||
|
# Empty if nothing is mergeable. UI shows a "Merge" button on any
|
||||||
|
# row whose assignment_id appears in a group.
|
||||||
|
"merge_groups": merge_groups,
|
||||||
}
|
}
|
||||||
|
|||||||
+316
-7
@@ -287,6 +287,16 @@
|
|||||||
↻ Refresh
|
↻ Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gantt chart — visual timeline of all deployments. Click
|
||||||
|
a bar to jump to its row in the list below. -->
|
||||||
|
<div id="deploymentGantt" class="mb-4 hidden">
|
||||||
|
<div class="bg-gray-50 dark:bg-slate-900/40 rounded-lg p-3">
|
||||||
|
<svg id="deploymentGanttSvg" class="w-full" style="height: 140px;" preserveAspectRatio="none"></svg>
|
||||||
|
<div id="deploymentGanttLegend" class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-gray-500 dark:text-gray-400"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="deploymentTimeline" class="space-y-3">
|
<div id="deploymentTimeline" class="space-y-3">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1986,6 +1996,10 @@ loadUnitData().then(() => {
|
|||||||
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
// Replaces the legacy loadDeploymentHistory() + loadUnitHistory() pair.
|
||||||
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
// Derives entries from unit_assignments + unit_history + SFM event overlay.
|
||||||
|
|
||||||
|
// Cache the most recent timeline payload so the merge action can look up
|
||||||
|
// which assignment_ids belong together in a mergeable group.
|
||||||
|
let _dtCurrentTimeline = { entries: [], merge_groups: [] };
|
||||||
|
|
||||||
async function loadDeploymentTimeline() {
|
async function loadDeploymentTimeline() {
|
||||||
const container = document.getElementById('deploymentTimeline');
|
const container = document.getElementById('deploymentTimeline');
|
||||||
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">Loading timeline…</p>';
|
||||||
@@ -1994,12 +2008,58 @@ async function loadDeploymentTimeline() {
|
|||||||
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
const r = await fetch(`/api/units/${currentUnit.id}/deployment_timeline`);
|
||||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
renderDeploymentTimeline(d.entries || [], container);
|
_dtCurrentTimeline = {
|
||||||
|
entries: d.entries || [],
|
||||||
|
merge_groups: d.merge_groups || [],
|
||||||
|
};
|
||||||
|
renderDeploymentTimeline(_dtCurrentTimeline.entries, container, _dtCurrentTimeline.merge_groups);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
container.innerHTML = `<p class="text-sm text-red-500">Failed to load timeline: ${e.message}</p>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the merge_group (list of assignment_ids) that this assignment is
|
||||||
|
// part of, or null if it isn't in any mergeable group.
|
||||||
|
function _dtFindMergeGroup(assignmentId) {
|
||||||
|
for (const group of _dtCurrentTimeline.merge_groups || []) {
|
||||||
|
if (group.includes(assignmentId)) return group;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeAssignmentGroup(assignmentIds) {
|
||||||
|
if (!Array.isArray(assignmentIds) || assignmentIds.length < 2) return;
|
||||||
|
const msg = `Merge ${assignmentIds.length} consecutive assignment records into one?\n\n`
|
||||||
|
+ `The earliest record is kept and its window extended to span all `
|
||||||
|
+ `of them. The other ${assignmentIds.length - 1} record(s) are deleted.\n\n`
|
||||||
|
+ `Original metadata (notes + ingest source) is preserved. This is `
|
||||||
|
+ `logged to the unit's history as "assignment_merged".`;
|
||||||
|
if (!confirm(msg)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// All assignments share the same project_id (validated server-side).
|
||||||
|
// Pick the first entry's project_id from the cache.
|
||||||
|
const first = (_dtCurrentTimeline.entries || []).find(e =>
|
||||||
|
e.kind === 'assignment' && assignmentIds.includes(e.assignment_id)
|
||||||
|
);
|
||||||
|
const projectId = first ? first.project_id : null;
|
||||||
|
if (!projectId) throw new Error('Could not resolve project id for this group');
|
||||||
|
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/assignments/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ assignment_ids: assignmentIds }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
|
||||||
|
throw new Error(err.detail || 'HTTP ' + r.status);
|
||||||
|
}
|
||||||
|
await loadDeploymentTimeline();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || 'Failed to merge assignments.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _dtFmtDate(iso) {
|
function _dtFmtDate(iso) {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
return iso.slice(0, 10);
|
return iso.slice(0, 10);
|
||||||
@@ -2044,6 +2104,17 @@ function _dtRenderAssignment(e) {
|
|||||||
? '<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>'
|
? '<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>'
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// If this assignment belongs to a mergeable group, show a small
|
||||||
|
// indicator badge — the group-level "Merge" action lives in the
|
||||||
|
// banner at the top of the section to avoid N redundant buttons.
|
||||||
|
const mergeGroup = _dtFindMergeGroup(e.assignment_id);
|
||||||
|
const mergeableBadge = mergeGroup
|
||||||
|
? `<span class="px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||||
|
title="This row is part of a ${mergeGroup.length}-record consecutive group at the same location — see the Merge banner above to combine them.">
|
||||||
|
mergeable
|
||||||
|
</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const overlay = evCount > 0
|
const overlay = evCount > 0
|
||||||
? `<div class="mt-2 flex items-center gap-4 text-xs text-gray-600 dark:text-gray-400">
|
? `<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>
|
<span><strong class="text-gray-900 dark:text-white">${evCount.toLocaleString()}</strong> event${evCount === 1 ? '' : 's'}</span>
|
||||||
@@ -2056,7 +2127,7 @@ function _dtRenderAssignment(e) {
|
|||||||
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
? `<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 italic">${_dtEsc(e.notes)}</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `<div class="flex gap-3">
|
return `<div class="flex gap-3 transition-shadow rounded-lg" data-assignment-row="${_dtEsc(e.assignment_id)}">
|
||||||
<div class="flex flex-col items-center pt-1">
|
<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>
|
<span class="w-3 h-3 rounded-full ${e.is_active ? 'bg-green-500' : 'bg-seismo-orange'}"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2065,7 +2136,10 @@ function _dtRenderAssignment(e) {
|
|||||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
<strong>${start}</strong> → <strong>${end}</strong>${dur}
|
||||||
</div>
|
</div>
|
||||||
${activeBadge}
|
<div class="flex items-center gap-2">
|
||||||
|
${mergeableBadge}
|
||||||
|
${activeBadge}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1">${locLink}</div>
|
<div class="mt-1">${locLink}</div>
|
||||||
${projLine}
|
${projLine}
|
||||||
@@ -2118,18 +2192,253 @@ function _dtRenderStateChange(e) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDeploymentTimeline(entries, container) {
|
// ── Gantt chart ─────────────────────────────────────────────────────────────
|
||||||
if (!entries.length) {
|
// Renders all assignment windows as colored horizontal bars on an SVG
|
||||||
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>';
|
// timeline. Click a bar to scroll its detail row into view in the list
|
||||||
|
// below. Color per location, opacity reduced for closed assignments.
|
||||||
|
// "Mergeable" groups get a unifying outline overlay so they're visible at
|
||||||
|
// a glance as one logical deployment.
|
||||||
|
const _ganttColorPalette = [
|
||||||
|
'#f48b1c', '#142a66', '#7d234d', '#0e7490', '#15803d', '#a16207',
|
||||||
|
'#9333ea', '#dc2626', '#0d9488', '#1d4ed8', '#be185d', '#65a30d',
|
||||||
|
];
|
||||||
|
function _ganttColorFor(locId, locColorMap) {
|
||||||
|
if (locColorMap[locId]) return locColorMap[locId];
|
||||||
|
const idx = Object.keys(locColorMap).length % _ganttColorPalette.length;
|
||||||
|
locColorMap[locId] = _ganttColorPalette[idx];
|
||||||
|
return locColorMap[locId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ganttParseDate(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso.replace(' ', 'T'));
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _ganttFmtMonth(d) {
|
||||||
|
return d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeploymentGantt(entries, mergeGroups) {
|
||||||
|
const wrapper = document.getElementById('deploymentGantt');
|
||||||
|
const svg = document.getElementById('deploymentGanttSvg');
|
||||||
|
const legend = document.getElementById('deploymentGanttLegend');
|
||||||
|
if (!wrapper || !svg) return;
|
||||||
|
|
||||||
|
const assignments = (entries || []).filter(e => e.kind === 'assignment' && e.starts_at);
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
wrapper.classList.add('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
wrapper.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Compute time domain. Pad the end by a few days when an active
|
||||||
|
// assignment is present so the "active" bar doesn't reach the very
|
||||||
|
// edge of the chart.
|
||||||
|
const now = new Date();
|
||||||
|
let minDate = null, maxDate = null;
|
||||||
|
for (const a of assignments) {
|
||||||
|
const start = _ganttParseDate(a.starts_at);
|
||||||
|
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
|
||||||
|
if (start && (!minDate || start < minDate)) minDate = start;
|
||||||
|
if (end && (!maxDate || end > maxDate)) maxDate = end;
|
||||||
|
}
|
||||||
|
if (!minDate || !maxDate) { wrapper.classList.add('hidden'); return; }
|
||||||
|
// Tiny padding at both ends (3% of total span).
|
||||||
|
const span = maxDate - minDate;
|
||||||
|
const pad = Math.max(span * 0.03, 24 * 3600 * 1000); // at least 1 day
|
||||||
|
minDate = new Date(minDate.getTime() - pad);
|
||||||
|
maxDate = new Date(maxDate.getTime() + pad);
|
||||||
|
|
||||||
|
// Build a quick "which mergeGroup is this id in?" map.
|
||||||
|
const idToGroup = {};
|
||||||
|
(mergeGroups || []).forEach((g, idx) => g.forEach(id => { idToGroup[id] = idx; }));
|
||||||
|
|
||||||
|
// Compute SVG geometry.
|
||||||
|
const width = Math.max(svg.clientWidth || svg.parentElement.clientWidth || 800, 400);
|
||||||
|
const height = 140;
|
||||||
|
const padLeft = 8;
|
||||||
|
const padRight = 8;
|
||||||
|
const padTop = 32; // room for month labels above the bars
|
||||||
|
const padBottom = 18; // room for assignment-count axis below
|
||||||
|
const usableW = width - padLeft - padRight;
|
||||||
|
const usableH = height - padTop - padBottom;
|
||||||
|
const totalRange = maxDate - minDate;
|
||||||
|
const xFor = (d) => padLeft + (d - minDate) / totalRange * usableW;
|
||||||
|
|
||||||
|
// Choose one-row-per-bar OR stack overlapping bars. Since same-unit
|
||||||
|
// assignments rarely overlap (only via the brief unassign/reassign
|
||||||
|
// race), a single row is usually fine. But just in case, stack with
|
||||||
|
// simple top-down packing.
|
||||||
|
const lanes = []; // each lane = [{x1, x2, ...}, ...]
|
||||||
|
function placeInLane(start, end) {
|
||||||
|
for (let i = 0; i < lanes.length; i++) {
|
||||||
|
const last = lanes[i][lanes[i].length - 1];
|
||||||
|
if (last.x2 + 2 < start) {
|
||||||
|
lanes[i].push({ x1: start, x2: end });
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lanes.push([{ x1: start, x2: end }]);
|
||||||
|
return lanes.length - 1;
|
||||||
|
}
|
||||||
|
const placed = assignments.map(a => {
|
||||||
|
const start = _ganttParseDate(a.starts_at);
|
||||||
|
const end = a.is_active ? now : (_ganttParseDate(a.ends_at) || now);
|
||||||
|
const x1 = xFor(start);
|
||||||
|
const x2 = xFor(end);
|
||||||
|
const lane = placeInLane(x1, x2);
|
||||||
|
return { a, x1, x2, lane };
|
||||||
|
});
|
||||||
|
const laneCount = Math.max(lanes.length, 1);
|
||||||
|
const barH = Math.max(14, Math.min(28, Math.floor(usableH / laneCount) - 4));
|
||||||
|
const laneSpacing = barH + 4;
|
||||||
|
|
||||||
|
// Month gridlines + labels. Tick on the 1st of each month inside the
|
||||||
|
// domain. If span > 24mo, tick every 3 months instead.
|
||||||
|
const months = [];
|
||||||
|
let monthCursor = new Date(minDate.getFullYear(), minDate.getMonth(), 1);
|
||||||
|
const tickEveryMonths = (totalRange > 24 * 30 * 86400 * 1000) ? 3 : 1;
|
||||||
|
while (monthCursor <= maxDate) {
|
||||||
|
if (monthCursor >= minDate) months.push(new Date(monthCursor));
|
||||||
|
monthCursor.setMonth(monthCursor.getMonth() + tickEveryMonths);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the SVG string.
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
const gridColor = isDark ? '#374151' : '#e5e7eb';
|
||||||
|
const labelColor = isDark ? '#9ca3af' : '#6b7280';
|
||||||
|
const todayColor = '#f48b1c';
|
||||||
|
|
||||||
|
const locColorMap = {};
|
||||||
|
const usedLocs = {};
|
||||||
|
|
||||||
|
let parts = [];
|
||||||
|
// Month gridlines.
|
||||||
|
months.forEach(m => {
|
||||||
|
const x = xFor(m);
|
||||||
|
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${gridColor}" stroke-width="1"/>`);
|
||||||
|
parts.push(`<text x="${x + 2}" y="${padTop - 6}" font-size="10" fill="${labelColor}" font-family="system-ui,sans-serif">${_ganttFmtMonth(m)}</text>`);
|
||||||
|
});
|
||||||
|
// Today marker.
|
||||||
|
if (now >= minDate && now <= maxDate) {
|
||||||
|
const x = xFor(now);
|
||||||
|
parts.push(`<line x1="${x}" y1="${padTop}" x2="${x}" y2="${height - padBottom}" stroke="${todayColor}" stroke-width="2" stroke-dasharray="3 2" opacity="0.8"/>`);
|
||||||
|
parts.push(`<text x="${x + 3}" y="${height - padBottom + 12}" font-size="9" fill="${todayColor}" font-family="system-ui,sans-serif">today</text>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bars.
|
||||||
|
placed.forEach(p => {
|
||||||
|
const a = p.a;
|
||||||
|
const color = _ganttColorFor(a.location_id || '_', locColorMap);
|
||||||
|
usedLocs[a.location_name || '(no location)'] = color;
|
||||||
|
const y = padTop + p.lane * laneSpacing;
|
||||||
|
const opacity = a.is_active ? 1.0 : 0.85;
|
||||||
|
const stroke = (a.source === 'metadata_backfill') ? '#3b82f6' : 'none';
|
||||||
|
const strokeWidth = (a.source === 'metadata_backfill') ? 2 : 0;
|
||||||
|
|
||||||
|
const barW = Math.max(p.x2 - p.x1, 3);
|
||||||
|
const tipDates = `${(a.starts_at || '').slice(0,10)} → ${a.is_active ? 'active' : (a.ends_at || '').slice(0,10)}`;
|
||||||
|
const tip = `${(a.location_name || '?').replace(/"/g, '"')} (${tipDates})${a.event_overlay && a.event_overlay.event_count ? ' • ' + a.event_overlay.event_count + ' events' : ''}`;
|
||||||
|
|
||||||
|
parts.push(`<g style="cursor: pointer;" onclick="_ganttScrollTo('${a.assignment_id}')">
|
||||||
|
<title>${tip}</title>
|
||||||
|
<rect x="${p.x1}" y="${y}" width="${barW}" height="${barH}" rx="3"
|
||||||
|
fill="${color}" opacity="${opacity}" stroke="${stroke}" stroke-width="${strokeWidth}"/>
|
||||||
|
${a.is_active ? `<circle cx="${p.x2 - 4}" cy="${y + barH / 2}" r="2.5" fill="#fff" opacity="0.9"/>` : ''}
|
||||||
|
</g>`);
|
||||||
|
|
||||||
|
// Mergeable highlight — thin dashed underline below the bar.
|
||||||
|
if (idToGroup[a.assignment_id] !== undefined) {
|
||||||
|
const uy = y + barH + 1;
|
||||||
|
parts.push(`<line x1="${p.x1}" y1="${uy}" x2="${p.x2}" y2="${uy}" stroke="#3b82f6" stroke-width="1.5" stroke-dasharray="2 2" opacity="0.7"/>`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.innerHTML = parts.join('');
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
||||||
|
|
||||||
|
// Build legend (one swatch per distinct location).
|
||||||
|
const legendItems = Object.entries(usedLocs).map(([name, color]) =>
|
||||||
|
`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded" style="background:${color}"></span>${_dtEsc(name)}</span>`
|
||||||
|
);
|
||||||
|
if (mergeGroups && mergeGroups.length > 0) {
|
||||||
|
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 border-b-2 border-dashed border-blue-500"></span>mergeable group</span>`);
|
||||||
|
}
|
||||||
|
if (placed.some(p => p.a.source === 'metadata_backfill')) {
|
||||||
|
legendItems.push(`<span class="flex items-center gap-1.5"><span class="inline-block w-3 h-2 rounded border-2 border-blue-500"></span>auto-backfilled</span>`);
|
||||||
|
}
|
||||||
|
legend.innerHTML = legendItems.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click-on-bar handler. Just scroll the matching list row into view +
|
||||||
|
// briefly flash it so the eye finds it.
|
||||||
|
function _ganttScrollTo(assignmentId) {
|
||||||
|
const target = document.querySelector(`[data-assignment-row="${assignmentId}"]`);
|
||||||
|
if (!target) return;
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
target.classList.add('ring-2', 'ring-seismo-orange');
|
||||||
|
setTimeout(() => target.classList.remove('ring-2', 'ring-seismo-orange'), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeploymentTimeline(entries, container, mergeGroups) {
|
||||||
|
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>';
|
||||||
|
// Hide the Gantt block too.
|
||||||
|
const g = document.getElementById('deploymentGantt');
|
||||||
|
if (g) g.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the Gantt chart first (above the list).
|
||||||
|
renderDeploymentGantt(entries, mergeGroups);
|
||||||
|
|
||||||
|
// Build the mergeable-groups banner. Each group offers one "Merge into
|
||||||
|
// one" button. Skipped when no groups exist.
|
||||||
|
let bannerHtml = '';
|
||||||
|
if (mergeGroups && mergeGroups.length > 0) {
|
||||||
|
const rows = mergeGroups.map(group => {
|
||||||
|
// Look up the entries to describe what we're merging.
|
||||||
|
const groupEntries = (entries || []).filter(e =>
|
||||||
|
e.kind === 'assignment' && group.includes(e.assignment_id)
|
||||||
|
);
|
||||||
|
if (groupEntries.length === 0) return '';
|
||||||
|
const locName = groupEntries[0].location_name || 'unnamed location';
|
||||||
|
const earliest = groupEntries.map(e => e.starts_at).filter(Boolean).sort()[0] || '';
|
||||||
|
const latest = groupEntries.map(e => e.ends_at).filter(Boolean).sort().reverse()[0] || 'present';
|
||||||
|
const idsJson = JSON.stringify(group).replace(/"/g, '"');
|
||||||
|
return `<div class="flex items-center justify-between gap-3 py-1.5">
|
||||||
|
<div class="text-sm text-blue-900 dark:text-blue-200 min-w-0 flex-1">
|
||||||
|
<strong>${group.length} consecutive records</strong> at <strong>${_dtEsc(locName)}</strong>
|
||||||
|
<span class="text-xs text-blue-700 dark:text-blue-300 ml-2">${_dtFmtDate(earliest)} → ${_dtFmtDate(latest)}</span>
|
||||||
|
</div>
|
||||||
|
<button onclick='mergeAssignmentGroup(${JSON.stringify(group)})'
|
||||||
|
class="px-3 py-1 text-xs rounded-full bg-blue-600 hover:bg-blue-700 text-white font-medium whitespace-nowrap">
|
||||||
|
Merge into one
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
bannerHtml = `<div class="mb-4 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
|
||||||
|
<div class="flex items-start gap-2 mb-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="text-xs text-blue-800 dark:text-blue-300">
|
||||||
|
Consecutive deployments at the same location detected. Combine them into a single record to clean up the view (notes + ingest sources are preserved).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${rows}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
const html = entries.map(e => {
|
const html = entries.map(e => {
|
||||||
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
if (e.kind === 'assignment') return _dtRenderAssignment(e);
|
||||||
if (e.kind === 'gap') return _dtRenderGap(e);
|
if (e.kind === 'gap') return _dtRenderGap(e);
|
||||||
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
if (e.kind === 'state_change') return _dtRenderStateChange(e);
|
||||||
return '';
|
return '';
|
||||||
}).join('');
|
}).join('');
|
||||||
container.innerHTML = html;
|
|
||||||
|
container.innerHTML = bannerHtml + '<div class="space-y-3">' + html + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SFM Events section ──────────────────────────────────────────────────────
|
// ── SFM Events section ──────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user