3 Commits

Author SHA1 Message Date
serversdown ef0008822e feat(timeline): merge consecutive same-location assignments + per-unit Gantt chart
When a unit had its assignment closed-then-reopened (e.g. via the
recent location remove/restore flow) or had metadata-backfill auto-
create a retroactive window adjacent to a manual one, the deployment
timeline showed N stacked rows that represented one continuous
deployment.  Visual noise that didn't match reality.

Merge feature
- New endpoint POST /api/projects/{p}/assignments/merge
  - Body: { assignment_ids: [uuid, ...] }
  - Keeps earliest record, extends its window to span all inputs,
    deletes the others, logs `assignment_merged` to UnitHistory
  - Validates: all assignments share same unit + location, all
    belong to the same project
- deployment_timeline_for_unit() now auto-detects mergeable groups
  (consecutive same-location assignments within 7-day gap tolerance)
  and returns them in `merge_groups` as a list of id-lists
- Unit detail page shows a blue banner above the timeline list when
  groups exist, with one "Merge into one" button per group.  Each
  mergeable row gets a small "mergeable" badge to make the
  relationship obvious.

Per-unit Gantt chart (Phase 1 of the deployment-history calendar)
- Plain-SVG horizontal timeline rendered above the existing Deployment
  Timeline list, ~140px tall
- One colored bar per assignment, color-keyed by location (auto-
  assigned palette + legend)
- Reduced opacity for closed bars; small white dot at the right edge
  of active bars; today marker as a dashed orange vertical line
- Month gridlines (or every-3-month gridlines when domain > 24 months)
- Metadata-backfilled assignments get a blue outline so you spot
  which were auto-attributed
- Mergeable groups get a dashed blue underline tying their bars
  together visually
- Click any bar → smooth-scrolls the matching list row into view
  and flashes a ring around it
- Hover any bar → tooltip with location + window + event count
- Auto-hides on units with no deployment history

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:29:51 +00:00
serversdown f13158e7bf feat(locations): delete assignment record for mis-clicks / duplicates
When an operator accidentally clicks Assign multiple times on the same
location (or assigns the wrong unit), the resulting bogus assignment
rows cluttered the location's deployment history with no way to clean
them up — Unassign just sets assigned_until to now, which preserves
the row.

New DELETE /api/projects/{p}/assignments/{a} endpoint hard-deletes the
row entirely, intended for mis-clicks that never represented a real
deployment.

Safety:
  - Refuses if any MonitoringSession exists in the assignment's window
    for the same (unit, location).  If there's a recording session
    backing it, this isn't a mis-click — operator should Edit or
    Unassign instead.
  - Records UnitHistory `assignment_deleted` so the unit's deployment
    timeline still shows the deletion happened, even though the row
    itself is gone.

UI: trash icon added next to the existing pencil (Edit) icon on each
row of the vibration location's "Deployment History" panel.  Confirms
intent with a descriptive prompt that explains the consequence
(attribution becomes unattributed for that window) and points to
Edit/Unassign as alternatives.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:11:29 +00:00
serversdown 3f0ec8f30b fix(locations): Remove/Restore buttons broken by quote collision in onclick
The buttons used inline `onclick="...({{ name | tojson }})"`, which
emits the location name as a JSON-quoted string with double quotes —
those double quotes collide with the onclick attribute's own double
quotes, terminating the attribute early.  Result: the browser parses
the attribute as broken HTML and the click handler never fires.

Switched both Remove and Restore to the data-attribute pattern the
Edit button already uses (data-loc-id / data-loc-name read via
this.dataset in the onclick).  Robust against any character in the
location name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:42:39 +00:00
5 changed files with 619 additions and 13 deletions
+222
View File
@@ -830,6 +830,228 @@ async def update_assignment(
} }
@router.delete("/assignments/{assignment_id}")
async def delete_assignment(
project_id: str,
assignment_id: str,
db: Session = Depends(get_db),
):
"""
Hard-delete an assignment record.
Use case: operator clicked Assign by mistake (or 8 times in a row) and
wants the bogus records gone — not just closed with an `assigned_until`
timestamp. The standard close-via-unassign path is for legitimate
deployments that ended; this is for mis-clicks that never actually
happened.
Safety:
- Refuses if any MonitoringSession exists for the same (unit, location)
within this assignment's window — that suggests the deployment was
real, and the operator should use unassign instead.
- Refuses if the assignment is the ONLY active assignment for a unit
currently shown as deployed AND a recording session is in progress.
Audit:
- Records UnitHistory `assignment_deleted` so the unit's deployment
timeline shows the deletion happened (even though the row itself
is gone).
"""
assignment = db.query(UnitAssignment).filter_by(
id=assignment_id,
project_id=project_id,
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Safety: is there a real recording history for this (unit, location)
# within the assignment's time window? If so, this isn't a mis-click —
# the operator should close it via unassign, not delete it.
window_start = assignment.assigned_at
window_end = assignment.assigned_until or datetime.utcnow()
real_sessions = db.query(MonitoringSession).filter(
and_(
MonitoringSession.location_id == assignment.location_id,
MonitoringSession.unit_id == assignment.unit_id,
MonitoringSession.start_time >= window_start,
MonitoringSession.start_time <= window_end,
)
).count()
if real_sessions > 0:
raise HTTPException(
status_code=400,
detail=(
f"Cannot delete this assignment — {real_sessions} monitoring "
f"session(s) were recorded under it. Use Unassign to close "
f"the window instead, which preserves the audit trail."
),
)
# Resolve location name for audit log before deletion.
location = db.query(MonitoringLocation).filter_by(
id=assignment.location_id
).first()
location_label = location.name if location else assignment.location_id
_record_assignment_history(
db,
unit_id=assignment.unit_id,
change_type="assignment_deleted",
old_value=f"{location_label} ({assignment.assigned_at:%Y-%m-%d}"
f"{assignment.assigned_until and assignment.assigned_until.strftime('%Y-%m-%d') or 'active'})",
new_value="deleted",
notes=(
"Assignment row removed — created in error or accidental duplicate."
),
)
db.delete(assignment)
db.commit()
return {
"success": True,
"message": "Assignment deleted.",
}
@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,
+37 -1
View File
@@ -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,7 +252,32 @@ 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)
@@ -253,4 +285,8 @@ async def deployment_timeline_for_unit(
"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,
} }
@@ -53,7 +53,9 @@
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"> class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
Edit Edit
</button> </button>
<button onclick="openRemoveLocationModal('{{ item.location.id }}', {{ item.location.name | tojson }})" <button data-loc-id="{{ item.location.id }}"
data-loc-name="{{ item.location.name | e }}"
onclick="openRemoveLocationModal(this.dataset.locId, this.dataset.locName)"
class="text-xs px-3 py-1 rounded-full bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300 hover:bg-amber-100" class="text-xs px-3 py-1 rounded-full bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300 hover:bg-amber-100"
title="Mark as no longer actively monitored — preserves historical events"> title="Mark as no longer actively monitored — preserves historical events">
Remove Remove
@@ -122,7 +124,9 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button onclick="restoreLocation('{{ item.location.id }}', {{ item.location.name | tojson }})" <button data-loc-id="{{ item.location.id }}"
data-loc-name="{{ item.location.name | e }}"
onclick="restoreLocation(this.dataset.locId, this.dataset.locName)"
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200" class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
title="Restore to active monitoring"> title="Restore to active monitoring">
Restore Restore
+315 -6
View File
@@ -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,8 +2136,11 @@ 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>
<div class="flex items-center gap-2">
${mergeableBadge}
${activeBadge} ${activeBadge}
</div> </div>
</div>
<div class="mt-1">${locLink}</div> <div class="mt-1">${locLink}</div>
${projLine} ${projLine}
${overlay} ${overlay}
@@ -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, '&quot;')} (${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, '&quot;');
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 ──────────────────────────────────────────────────────
+35
View File
@@ -583,6 +583,14 @@ function renderAssignmentsUsed(assignments) {
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg> </svg>
</button> </button>
<button type="button"
onclick="deleteAssignment('${esc(a.assignment_id)}', '${esc(a.unit_id)}', '${start}${end}')"
title="Delete this assignment record (for mis-clicks / duplicates)"
class="text-gray-400 hover:text-red-600 transition-colors p-1">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
</svg>
</button>
</div> </div>
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span> <span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
</div>`; </div>`;
@@ -608,6 +616,33 @@ function openAssignmentEditModal(encodedJson) {
document.getElementById('assignment-edit-modal').classList.remove('hidden'); document.getElementById('assignment-edit-modal').classList.remove('hidden');
} }
async function deleteAssignment(assignmentId, unitId, windowLabel) {
// For mis-clicks / accidental duplicate assignments. Backend refuses
// if there's a real recording session inside the window — those should
// go through Edit or Unassign instead.
const msg = `Delete this assignment?\n\n`
+ `Unit: ${unitId}\n`
+ `Window: ${windowLabel}\n\n`
+ `This is for assignments created in error. Events that fell `
+ `in this window will become unattributed. The unit's deployment `
+ `history will log the deletion for audit.\n\n`
+ `If the unit actually was deployed here, use Edit or Unassign instead.`;
if (!confirm(msg)) return;
try {
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
method: 'DELETE',
});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
await loadLocationEvents(); // Refresh stats + table without this assignment.
} catch (err) {
alert(err.message || 'Failed to delete assignment.');
}
}
function closeAssignmentEditModal() { function closeAssignmentEditModal() {
document.getElementById('assignment-edit-modal').classList.add('hidden'); document.getElementById('assignment-edit-modal').classList.add('hidden');
} }